typera-openapi
Advanced tools
Comparing version 1.0.3 to 2.0.0
@@ -1,2 +0,1 @@ | ||
#!/usr/bin/env node | ||
export {}; |
@@ -1,2 +0,1 @@ | ||
#!/usr/bin/env node | ||
"use strict"; | ||
@@ -27,6 +26,13 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
}) | ||
.option('format', { | ||
description: 'Output file format', | ||
choices: ['ts', 'json'], | ||
default: 'ts', | ||
.option('outfile', { | ||
alias: 'o', | ||
description: 'Output file. Must end in `.ts` or `.json`.', | ||
default: 'openapi.ts', | ||
coerce: (arg) => { | ||
if (arg.endsWith('.ts')) | ||
return [arg, 'ts']; | ||
if (arg.endsWith('.json')) | ||
return [arg, 'json']; | ||
throw new Error('outfile must end in `.ts` or `.json`'); | ||
}, | ||
}) | ||
@@ -39,3 +45,3 @@ .option('tsconfig', { | ||
alias: 'p', | ||
description: 'Apply prettier to output files', | ||
description: 'Apply prettier to the output file', | ||
type: 'boolean', | ||
@@ -46,11 +52,14 @@ default: false, | ||
alias: 'c', | ||
description: 'Exit with an error if output files are not up-to-date (useful for CI)', | ||
description: 'Exit with an error if the output file is not up-to-date (useful for CI)', | ||
type: 'boolean', | ||
default: false, | ||
}).argv; | ||
const outputFileName = (sourceFileName, ext) => sourceFileName.slice(0, -path.extname(sourceFileName).length) + ext; | ||
const main = () => __awaiter(void 0, void 0, void 0, function* () { | ||
const args = parseArgs(); | ||
const sourceFiles = args._.map((x) => path.resolve(x.toString())); | ||
const ext = `.openapi.${args.format}`; | ||
const sourceFiles = args._.map((x) => x.toString()); | ||
if (sourceFiles.length === 0) { | ||
console.error('error: No source files given'); | ||
return 1; | ||
} | ||
const [outfile, format] = args.outfile; | ||
const compilerOptions = readCompilerOptions(args.tsconfig); | ||
@@ -62,22 +71,21 @@ if (!compilerOptions) | ||
} | ||
const results = _1.generate(sourceFiles, compilerOptions, { | ||
const { output, unseenFileNames } = (0, _1.generate)(sourceFiles, compilerOptions, { | ||
log, | ||
}).map((result) => (Object.assign(Object.assign({}, result), { outputFileName: outputFileName(result.fileName, ext) }))); | ||
let success = true; | ||
for (const { outputFileName, paths } of results) { | ||
let content = args.format === 'ts' ? tsString(paths) : jsonString(paths); | ||
if (args.prettify) { | ||
content = yield prettify_1.runPrettier(outputFileName, content); | ||
} | ||
if (args.check) { | ||
if (!checkOutput(outputFileName, content)) | ||
success = false; | ||
} | ||
else { | ||
writeOutput(outputFileName, content); | ||
} | ||
}); | ||
if (unseenFileNames.length > 0) { | ||
console.error(`error: The following files don't exist or didn't contain any routes: ${unseenFileNames.join(', ')}`); | ||
return 1; | ||
} | ||
if (!success) { | ||
process.exit(1); | ||
let content = format === 'ts' ? tsString(output) : jsonString(output); | ||
if (args.prettify) { | ||
content = yield (0, prettify_1.runPrettier)(outfile, content); | ||
} | ||
if (args.check) { | ||
if (!checkOutput(outfile, content)) | ||
return 1; | ||
} | ||
else { | ||
writeOutput(outfile, content); | ||
} | ||
return 0; | ||
}); | ||
@@ -114,10 +122,13 @@ const readCompilerOptions = (tsconfigPath) => { | ||
}; | ||
const tsString = (paths) => `\ | ||
const tsString = (result) => `\ | ||
import { OpenAPIV3 } from 'openapi-types' | ||
const spec: { paths: OpenAPIV3.PathsObject } = ${JSON.stringify({ paths })}; | ||
const spec: { paths: OpenAPIV3.PathsObject, components: OpenAPIV3.ComponentsObject } = ${JSON.stringify(result)}; | ||
export default spec; | ||
`; | ||
const jsonString = (paths) => JSON.stringify({ paths }); | ||
main(); | ||
const jsonString = (result) => JSON.stringify(result); | ||
main().then((status) => { | ||
if (status !== 0) | ||
process.exit(status); | ||
}); |
@@ -7,7 +7,11 @@ import * as ts from 'typescript'; | ||
} | ||
interface Result { | ||
fileName: string; | ||
export interface GenerateOutput { | ||
paths: OpenAPIV3.PathsObject; | ||
components: OpenAPIV3.ComponentsObject; | ||
} | ||
export declare const generate: (fileNames: string[], compilerOptions: ts.CompilerOptions, options?: GenerateOptions | undefined) => Result[]; | ||
export interface GenerateResult { | ||
output: GenerateOutput; | ||
unseenFileNames: string[]; | ||
} | ||
export declare const generate: (fileNames: string[], compilerOptions: ts.CompilerOptions, options?: GenerateOptions | undefined) => GenerateResult; | ||
export {}; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.generate = void 0; | ||
const path = require("path"); | ||
const ts = require("typescript"); | ||
@@ -8,2 +9,3 @@ const statuses = require("statuses"); | ||
const utils_1 = require("./utils"); | ||
const components_1 = require("./components"); | ||
const generate = (fileNames, compilerOptions, options) => { | ||
@@ -13,20 +15,34 @@ const log = (options === null || options === void 0 ? void 0 : options.log) || (() => undefined); | ||
const checker = program.getTypeChecker(); | ||
const result = []; | ||
const seenFileNames = new Set(); | ||
const components = new components_1.Components(); | ||
let paths = {}; | ||
for (const sourceFile of program.getSourceFiles()) { | ||
if (!fileNames.includes(sourceFile.fileName)) | ||
continue; | ||
if (sourceFile.isDeclarationFile) | ||
continue; | ||
const foundFile = fileNames.find((fileName) => isSameFile(fileName, sourceFile.fileName)); | ||
if (foundFile === undefined) | ||
continue; | ||
let containsRoutes = false; | ||
ts.forEachChild(sourceFile, (node) => { | ||
const paths = visitTopLevelNode(context_1.context(checker, sourceFile, log, node), node); | ||
if (paths) { | ||
result.push({ fileName: sourceFile.fileName, paths }); | ||
const newPaths = visitTopLevelNode((0, context_1.context)(checker, sourceFile, log, node), components, node); | ||
if (newPaths) { | ||
// TODO: What if a route is defined multiple times? | ||
paths = Object.assign(Object.assign({}, paths), newPaths); | ||
containsRoutes = true; | ||
} | ||
}); | ||
if (containsRoutes) { | ||
seenFileNames.add(foundFile); | ||
} | ||
} | ||
return result; | ||
return { | ||
output: { paths, components: components.build() }, | ||
unseenFileNames: fileNames.filter((fileName) => !seenFileNames.has(fileName)), | ||
}; | ||
}; | ||
exports.generate = generate; | ||
const visitTopLevelNode = (ctx, node) => { | ||
const isSameFile = (a, b) => path.resolve(a) === path.resolve(b); | ||
const visitTopLevelNode = (ctx, components, node) => { | ||
if (ts.isExportAssignment(node) && !node.isExportEquals) { | ||
const prefix = getRouterPrefix(node); | ||
// 'export default' statement | ||
@@ -47,8 +63,9 @@ const argSymbols = getRouterCallArgSymbols(ctx, node.expression); | ||
} | ||
const routeDeclaration = getRouteDeclaration(context_1.withLocation(ctx, location), symbol); | ||
const routeDeclaration = getRouteDeclaration((0, context_1.withLocation)(ctx, location), components, symbol); | ||
if (routeDeclaration) { | ||
const [path, method, operation] = routeDeclaration; | ||
const pathsItemObject = paths[path]; | ||
const prefixedPath = prefix + path; | ||
const pathsItemObject = paths[prefixedPath]; | ||
if (!pathsItemObject) { | ||
paths[path] = { [method]: operation }; | ||
paths[prefixedPath] = { [method]: operation }; | ||
} | ||
@@ -79,3 +96,3 @@ else { | ||
}; | ||
const getRouteDeclaration = (ctx, symbol) => { | ||
const getRouteDeclaration = (ctx, components, symbol) => { | ||
const description = getDescriptionFromComment(ctx, symbol); | ||
@@ -89,8 +106,11 @@ const summary = getRouteSummary(symbol); | ||
} | ||
const { method, path, requestNode, body, query, headers, routeParams, cookies, } = routeInput; | ||
const responses = getResponseTypes(ctx, symbol); | ||
const { method, path, requestNode, body, query, headers, routeParams, cookies, contentType, } = routeInput; | ||
const contentTypeString = contentType && (0, utils_1.isStringLiteralType)(contentType) | ||
? ctx.checker.typeToString(contentType).replace(/"/g, '') | ||
: undefined; | ||
const responses = getResponseTypes(ctx, components, symbol); | ||
if (!responses) | ||
return; | ||
const requestBody = requestNode && body | ||
? typeToSchema(context_1.withLocation(ctx, requestNode), body) | ||
? typeToSchema((0, context_1.withLocation)(ctx, requestNode), components, body) | ||
: undefined; | ||
@@ -107,3 +127,3 @@ const parameters = [ | ||
method, | ||
Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (summary ? { summary } : undefined)), (description ? { description } : undefined)), (tags && tags.length > 0 ? { tags } : undefined)), (parameters.length > 0 ? { parameters } : undefined)), operationRequestBody(requestBody)), { responses }), | ||
Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (summary ? { summary } : undefined)), (description ? { description } : undefined)), (tags && tags.length > 0 ? { tags } : undefined)), (parameters.length > 0 ? { parameters } : undefined)), operationRequestBody(requestBody, contentTypeString)), { responses }), | ||
]; | ||
@@ -128,7 +148,14 @@ }; | ||
.map((tag) => tag.trim()); | ||
const operationRequestBody = (contentSchema) => { | ||
const getRouterPrefix = (node) => { | ||
var _a; | ||
return (_a = ts | ||
.getJSDocTags(node) | ||
.filter((tag) => tag.tagName.escapedText === 'prefix') | ||
.flatMap((tag) => typeof tag.comment === 'string' ? [tag.comment] : [])[0]) !== null && _a !== void 0 ? _a : ''; | ||
}; | ||
const operationRequestBody = (contentSchema, contentType = 'application/json') => { | ||
if (!contentSchema) | ||
return; | ||
return { | ||
requestBody: { content: { 'application/json': { schema: contentSchema } } }, | ||
requestBody: { content: { [contentType]: { schema: contentSchema } } }, | ||
}; | ||
@@ -157,3 +184,3 @@ }; | ||
return; | ||
let method, path, requestNode, body, query, routeParams, headers, cookies; | ||
let method, path, requestNode, body, query, routeParams, headers, cookies, contentType; | ||
while (ts.isCallExpression(expr)) { | ||
@@ -234,3 +261,3 @@ const lhs = expr.expression; | ||
const reqType = routeHandlerParamTypes[0]; | ||
[body, query, headers, routeParams, cookies] = [ | ||
[body, query, headers, routeParams, cookies, contentType] = [ | ||
'body', | ||
@@ -241,3 +268,4 @@ 'query', | ||
'cookies', | ||
].map((property) => utils_1.getPropertyType(ctx.checker, routeConstructor, reqType, property)); | ||
'contentType', | ||
].map((property) => (0, utils_1.getPropertyType)(ctx.checker, routeConstructor, reqType, property)); | ||
requestNode = routeConstructor; | ||
@@ -268,5 +296,6 @@ } | ||
cookies, | ||
contentType, | ||
}; | ||
}; | ||
const getResponseTypes = (ctx, symbol) => { | ||
const getResponseTypes = (ctx, components, symbol) => { | ||
const descriptions = getResponseDescriptions(symbol); | ||
@@ -297,3 +326,3 @@ const location = symbol.valueDeclaration; | ||
// returnType is a Promise | ||
const responseType = utils_1.getPromisePayloadType(context_1.withLocation(ctx, location), returnTypes[0]); | ||
const responseType = (0, utils_1.getPromisePayloadType)((0, context_1.withLocation)(ctx, location), returnTypes[0]); | ||
if (!responseType) { | ||
@@ -304,4 +333,4 @@ ctx.log('warn', 'Not a valid route: routeHandler does not return a promise'); | ||
const result = {}; | ||
if (utils_1.isObjectType(responseType)) { | ||
const responseDef = getResponseDefinition(ctx, descriptions, responseType); | ||
if ((0, utils_1.isObjectType)(responseType)) { | ||
const responseDef = getResponseDefinition(ctx, components, descriptions, responseType); | ||
if (responseDef) | ||
@@ -312,3 +341,3 @@ result[responseDef.status] = responseDef.response; | ||
responseType.types.forEach((type) => { | ||
const responseDef = getResponseDefinition(ctx, descriptions, type); | ||
const responseDef = getResponseDefinition(ctx, components, descriptions, type); | ||
if (responseDef) | ||
@@ -337,3 +366,3 @@ result[responseDef.status] = responseDef.response; | ||
.filter(utils_1.isDefined)); | ||
const getResponseDefinition = (ctx, responseDescriptions, responseType) => { | ||
const getResponseDefinition = (ctx, components, responseDescriptions, responseType) => { | ||
const statusSymbol = responseType.getProperty('status'); | ||
@@ -352,15 +381,13 @@ const bodySymbol = responseType.getProperty('body'); | ||
const status = ctx.checker.typeToString(statusType); | ||
if (!utils_1.isNumberLiteralType(statusType)) { | ||
if (!(0, utils_1.isNumberLiteralType)(statusType)) { | ||
ctx.log('warn', `Status code is not a number literal: ${status}`); | ||
return; | ||
} | ||
// TODO: If bodyType is an interface (or type alias?), generate a schema | ||
// component object and a reference to it? | ||
let bodySchema; | ||
if (!utils_1.isUndefinedType(bodyType)) { | ||
bodySchema = typeToSchema(ctx, bodyType); | ||
if (!(0, utils_1.isUndefinedType)(bodyType)) { | ||
bodySchema = typeToSchema(ctx, components, bodyType); | ||
if (!bodySchema) | ||
return; | ||
} | ||
const headers = !utils_1.isUndefinedType(headersType) | ||
const headers = !(0, utils_1.isUndefinedType)(headersType) | ||
? typeToHeaders(ctx, headersType) | ||
@@ -380,5 +407,5 @@ : undefined; | ||
? { | ||
content: utils_1.isStringType(bodyType) || utils_1.isNumberType(bodyType) | ||
content: (0, utils_1.isStringType)(bodyType) || (0, utils_1.isNumberType)(bodyType) | ||
? { 'text/plain': { schema: bodySchema } } | ||
: utils_1.isBufferType(bodyType) | ||
: (0, utils_1.isBufferType)(bodyType) | ||
? { 'application/octet-stream': { schema: bodySchema } } | ||
@@ -400,3 +427,3 @@ : { 'application/json': { schema: bodySchema } }, | ||
const description = getDescriptionFromComment(ctx, prop); | ||
return Object.assign({ name: prop.name, in: in_, required: in_ === 'path' ? true : !utils_1.isOptional(prop) }, (description ? { description } : undefined)); | ||
return Object.assign({ name: prop.name, in: in_, required: in_ === 'path' ? true : !(0, utils_1.isOptional)(prop) }, (description ? { description } : undefined)); | ||
}); | ||
@@ -409,3 +436,3 @@ }; | ||
result[prop.name] = { | ||
required: !utils_1.isOptional(prop), | ||
required: !(0, utils_1.isOptional)(prop), | ||
}; | ||
@@ -419,107 +446,117 @@ }); | ||
}; | ||
const typeToSchema = (ctx, type, options = {}) => { | ||
let base = getBaseSchema(ctx, options.symbol); | ||
if (type.isUnion()) { | ||
let elems = type.types; | ||
if (options.optional) { | ||
elems = type.types.filter((elem) => !utils_1.isUndefinedType(elem)); | ||
const typeToSchema = (ctx, components, type, options = {}) => { | ||
var _a; | ||
return components.withSymbol((_a = type.aliasSymbol) !== null && _a !== void 0 ? _a : type.getSymbol(), (addComponent) => { | ||
let base = getBaseSchema(ctx, options.propSymbol); | ||
if (type.isUnion()) { | ||
let elems = type.types; | ||
if (options.optional) { | ||
elems = type.types.filter((elem) => !(0, utils_1.isUndefinedType)(elem)); | ||
} | ||
if (elems.some(utils_1.isNullType)) { | ||
// One of the union elements is null | ||
base = Object.assign(Object.assign({}, base), { nullable: true }); | ||
elems = elems.filter((elem) => !(0, utils_1.isNullType)(elem)); | ||
} | ||
if (elems.every(utils_1.isBooleanLiteralType)) { | ||
// All elements are boolean literals => boolean | ||
return Object.assign({ type: 'boolean' }, base); | ||
} | ||
if (elems.every(utils_1.isNumberLiteralType)) { | ||
// All elements are number literals => enum | ||
addComponent(); | ||
return Object.assign({ type: 'number', enum: elems.map((elem) => elem.value) }, base); | ||
} | ||
else if (elems.every(utils_1.isStringLiteralType)) { | ||
// All elements are string literals => enum | ||
addComponent(); | ||
return Object.assign({ type: 'string', enum: elems.map((elem) => elem.value) }, base); | ||
} | ||
else if (elems.length >= 2) { | ||
// 2 or more types remain => anyOf | ||
addComponent(); | ||
return Object.assign({ anyOf: elems | ||
.map((elem) => typeToSchema(ctx, components, elem)) | ||
.filter(utils_1.isDefined) }, base); | ||
} | ||
else { | ||
// Only one element left in the union. Fall through and consider it as the | ||
// sole type. | ||
type = elems[0]; | ||
} | ||
} | ||
if (elems.some(utils_1.isNullType)) { | ||
// One of the union elements is null | ||
base = Object.assign(Object.assign({}, base), { nullable: true }); | ||
elems = elems.filter((elem) => !utils_1.isNullType(elem)); | ||
if ((0, utils_1.isArrayType)(type)) { | ||
const elemType = type.getNumberIndexType(); | ||
if (!elemType) { | ||
ctx.log('warn', 'Could not get array element type'); | ||
return; | ||
} | ||
const elemSchema = typeToSchema(ctx, components, elemType); | ||
if (!elemSchema) | ||
return; | ||
return Object.assign({ type: 'array', items: elemSchema }, base); | ||
} | ||
if (elems.every(utils_1.isBooleanLiteralType)) { | ||
// All elements are boolean literals => boolean | ||
return Object.assign({ type: 'boolean' }, base); | ||
if ((0, utils_1.isDateType)(type)) { | ||
// TODO: dates are always represented as date-time strings. It should be | ||
// possible to override this. | ||
return Object.assign({ type: 'string', format: 'date-time' }, base); | ||
} | ||
else if (elems.every(utils_1.isNumberLiteralType)) { | ||
// All elements are number literals => enum | ||
return Object.assign({ type: 'number', enum: elems.map((elem) => elem.value) }, base); | ||
if ((0, utils_1.isBufferType)(type)) { | ||
return Object.assign({ type: 'string', format: 'binary' }, base); | ||
} | ||
else if (elems.every(utils_1.isStringLiteralType)) { | ||
// All elements are string literals => enum | ||
return Object.assign({ type: 'string', enum: elems.map((elem) => elem.value) }, base); | ||
if ((0, utils_1.isObjectType)(type) || | ||
(type.isIntersection() && | ||
type.types.every((part) => (0, utils_1.isObjectType)(part)))) { | ||
addComponent(); | ||
const props = ctx.checker.getPropertiesOfType(type); | ||
return Object.assign(Object.assign({ type: 'object', required: props | ||
.filter((prop) => !(0, utils_1.isOptional)(prop)) | ||
.map((prop) => prop.name) }, base), { properties: Object.fromEntries(props | ||
.map((prop) => { | ||
const propType = ctx.checker.getTypeOfSymbolAtLocation(prop, ctx.location); | ||
if (!propType) { | ||
ctx.log('warn', 'Could not get type for property', prop.name); | ||
return; | ||
} | ||
const propSchema = typeToSchema(ctx, components, propType, { | ||
propSymbol: prop, | ||
optional: (0, utils_1.isOptional)(prop), | ||
}); | ||
if (!propSchema) { | ||
ctx.log('warn', 'Could not get schema for property', prop.name); | ||
return; | ||
} | ||
return [prop.name, propSchema]; | ||
}) | ||
.filter(utils_1.isDefined)) }); | ||
} | ||
else if (elems.length >= 2) { | ||
// 2 or more types remain => anyOf | ||
return Object.assign({ anyOf: elems.map((elem) => typeToSchema(ctx, elem)).filter(utils_1.isDefined) }, base); | ||
if ((0, utils_1.isStringType)(type)) { | ||
return Object.assign({ type: 'string' }, base); | ||
} | ||
else { | ||
// Only one element left in the union. Fall through and consider it as the | ||
// sole type. | ||
type = elems[0]; | ||
if ((0, utils_1.isNumberType)(type)) { | ||
return Object.assign({ type: 'number' }, base); | ||
} | ||
} | ||
if (utils_1.isArrayType(type)) { | ||
const elemType = type.getNumberIndexType(); | ||
if (!elemType) { | ||
ctx.log('warn', 'Could not get array element type'); | ||
return; | ||
if ((0, utils_1.isBooleanType)(type)) { | ||
return Object.assign({ type: 'boolean' }, base); | ||
} | ||
const elemSchema = typeToSchema(ctx, elemType); | ||
if (!elemSchema) | ||
return; | ||
return Object.assign({ type: 'array', items: elemSchema }, base); | ||
} | ||
if (utils_1.isDateType(type)) { | ||
// TODO: dates are always represented as date-time strings. It should be | ||
// possible to override this. | ||
return Object.assign({ type: 'string', format: 'date-time' }, base); | ||
} | ||
if (utils_1.isBufferType(type)) { | ||
return Object.assign({ type: 'string', format: 'binary' }, base); | ||
} | ||
if (utils_1.isObjectType(type) || | ||
(type.isIntersection() && type.types.every((part) => utils_1.isObjectType(part)))) { | ||
const props = ctx.checker.getPropertiesOfType(type); | ||
return Object.assign(Object.assign({ type: 'object', required: props | ||
.filter((prop) => !utils_1.isOptional(prop)) | ||
.map((prop) => prop.name) }, base), { properties: Object.fromEntries(props | ||
.map((prop) => { | ||
const propType = ctx.checker.getTypeOfSymbolAtLocation(prop, ctx.location); | ||
if (!propType) { | ||
ctx.log('warn', 'Could not get type for property', prop.name); | ||
return; | ||
} | ||
const propSchema = typeToSchema(ctx, propType, { | ||
symbol: prop, | ||
optional: utils_1.isOptional(prop), | ||
}); | ||
if (!propSchema) { | ||
ctx.log('warn', 'Could not get schema for property', prop.name); | ||
return; | ||
} | ||
return [prop.name, propSchema]; | ||
}) | ||
.filter(utils_1.isDefined)) }); | ||
} | ||
if (utils_1.isStringType(type)) { | ||
return Object.assign({ type: 'string' }, base); | ||
} | ||
if (utils_1.isNumberType(type)) { | ||
return Object.assign({ type: 'number' }, base); | ||
} | ||
if (utils_1.isBooleanType(type)) { | ||
return Object.assign({ type: 'boolean' }, base); | ||
} | ||
if (utils_1.isStringLiteralType(type)) { | ||
return Object.assign({ type: 'string', enum: [type.value] }, base); | ||
} | ||
if (utils_1.isNumberLiteralType(type)) { | ||
return Object.assign({ type: 'number', enum: [type.value] }, base); | ||
} | ||
const branded = utils_1.getBrandedType(ctx, type); | ||
if (branded) { | ||
// io-ts branded type | ||
const { brandName, brandedType } = branded; | ||
if (brandName === 'Brand<IntBrand>') { | ||
// io-ts Int | ||
return Object.assign({ type: 'integer' }, base); | ||
if ((0, utils_1.isStringLiteralType)(type)) { | ||
return Object.assign({ type: 'string', enum: [type.value] }, base); | ||
} | ||
// other branded type | ||
return typeToSchema(ctx, brandedType, options); | ||
} | ||
ctx.log('warn', `Ignoring an unknown type: ${ctx.checker.typeToString(type)}`); | ||
return; | ||
if ((0, utils_1.isNumberLiteralType)(type)) { | ||
return Object.assign({ type: 'number', enum: [type.value] }, base); | ||
} | ||
const branded = (0, utils_1.getBrandedType)(ctx, type); | ||
if (branded) { | ||
// io-ts branded type | ||
const { brandName, brandedType } = branded; | ||
if (brandName === 'Brand<IntBrand>') { | ||
// io-ts Int | ||
return Object.assign({ type: 'integer' }, base); | ||
} | ||
// other branded type | ||
return typeToSchema(ctx, components, brandedType, options); | ||
} | ||
ctx.log('warn', `Ignoring an unknown type: ${ctx.checker.typeToString(type)}`); | ||
return; | ||
}); | ||
}; |
@@ -1,4 +0,2 @@ | ||
export { generate } from './generate'; | ||
export { GenerateResult, GenerateOutput, generate } from './generate'; | ||
export { LogLevel } from './context'; | ||
import { OpenAPIV3 } from 'openapi-types'; | ||
export declare const prefix: (prefix: string, paths: OpenAPIV3.PathsObject) => OpenAPIV3.PathsObject; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.prefix = exports.generate = void 0; | ||
exports.generate = void 0; | ||
var generate_1 = require("./generate"); | ||
Object.defineProperty(exports, "generate", { enumerable: true, get: function () { return generate_1.generate; } }); | ||
const prefix = (prefix, paths) => Object.fromEntries(Object.entries(paths).map(([path, value]) => [prefix + path, value])); | ||
exports.prefix = prefix; |
@@ -15,2 +15,4 @@ import * as ts from 'typescript'; | ||
export declare const isNullType: (type: ts.Type) => boolean; | ||
export declare const isInterface: (symbol: ts.Symbol) => boolean; | ||
export declare const isTypeAlias: (symbol: ts.Symbol) => boolean; | ||
export declare const isDateType: (type: ts.Type) => boolean; | ||
@@ -17,0 +19,0 @@ export declare const isBufferType: (type: ts.Type) => boolean; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.getPromisePayloadType = exports.getBrandedType = exports.getPropertyType = exports.isBufferType = exports.isDateType = exports.isNullType = exports.isUndefinedType = exports.isStringLiteralType = exports.isNumberLiteralType = exports.isBooleanLiteralType = exports.isBooleanType = exports.isNumberType = exports.isStringType = exports.isObjectType = exports.isArrayType = exports.isOptional = exports.isDefined = void 0; | ||
exports.getPromisePayloadType = exports.getBrandedType = exports.getPropertyType = exports.isBufferType = exports.isDateType = exports.isTypeAlias = exports.isInterface = exports.isNullType = exports.isUndefinedType = exports.isStringLiteralType = exports.isNumberLiteralType = exports.isBooleanLiteralType = exports.isBooleanType = exports.isNumberType = exports.isStringType = exports.isObjectType = exports.isArrayType = exports.isOptional = exports.isDefined = void 0; | ||
const ts = require("typescript"); | ||
@@ -29,6 +29,10 @@ const isDefined = (value) => value !== undefined; | ||
exports.isNullType = isNullType; | ||
const isInterface = (symbol) => !!(symbol.flags & ts.SymbolFlags.Interface); | ||
exports.isInterface = isInterface; | ||
const isTypeAlias = (symbol) => !!(symbol.flags & ts.SymbolFlags.TypeAlias); | ||
exports.isTypeAlias = isTypeAlias; | ||
// Check for a specific object type based on type name and property names | ||
const duckTypeChecker = (name, properties) => (type) => { | ||
const symbol = type.symbol; | ||
return (exports.isObjectType(type) && | ||
return ((0, exports.isObjectType)(type) && | ||
symbol.escapedName === name && | ||
@@ -86,3 +90,3 @@ // If it walks like a duck and it quacks like a duck, then it must be a duck | ||
const onResolvedType = thenParamType.isUnion() | ||
? thenParamType.types.find((t) => !exports.isUndefinedType(t) && !exports.isNullType(t)) | ||
? thenParamType.types.find((t) => !(0, exports.isUndefinedType)(t) && !(0, exports.isNullType)(t)) | ||
: thenParamType; | ||
@@ -89,0 +93,0 @@ if (!onResolvedType) |
{ | ||
"name": "typera-openapi", | ||
"version": "1.0.3", | ||
"version": "2.0.0", | ||
"description": "Generate OpenAPI spec from typera routes", | ||
@@ -17,3 +17,3 @@ "repository": "https://github.com/akheron/typera-openapi", | ||
"scripts": { | ||
"build": "tsc", | ||
"build": "tsc -p tsconfig.build.json", | ||
"clean": "rm -rf dist", | ||
@@ -27,3 +27,3 @@ "lint": "eslint --max-warnings 0 '**/*.ts' && prettier --check \"**/*.{json,md}\"", | ||
"dependencies": { | ||
"openapi-types": "^9.0.0", | ||
"openapi-types": "^10.0.0", | ||
"statuses": "^2.0.1", | ||
@@ -34,3 +34,3 @@ "typescript": ">=4.3.0", | ||
"devDependencies": { | ||
"@types/jest": "^26.0.20", | ||
"@types/jest": "^27.0.1", | ||
"@types/node": "*", | ||
@@ -43,3 +43,3 @@ "@types/statuses": "^2.0.0", | ||
"eslint-config-prettier": "^8.1.0", | ||
"eslint-plugin-prettier": "^3.3.1", | ||
"eslint-plugin-prettier": "^4.0.0", | ||
"io-ts": "^2.2.13", | ||
@@ -46,0 +46,0 @@ "io-ts-types": "^0.5.12", |
@@ -8,2 +8,4 @@ # typera-openapi - OpenAPI generator for typera | ||
Upgrading to v2? See the [upgrading instructions](docs/upgrading.md). | ||
<!-- START doctoc generated TOC please keep comment here to allow auto update --> | ||
@@ -18,2 +20,3 @@ <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> | ||
- [The `Date` type](#the-date-type) | ||
- [Reference](#reference) | ||
- [Releasing](#releasing) | ||
@@ -64,3 +67,8 @@ | ||
/** | ||
* @prefix /api | ||
*/ | ||
export default router(myRoute, ...) | ||
// The optional @prefix JSDoc tag prepends the prefix to all route paths. | ||
``` | ||
@@ -88,3 +96,3 @@ | ||
import myRoutes from './my-routes' | ||
import myRouteDefs from './my-routes.openapi' | ||
import openapi from './openapi' | ||
@@ -97,5 +105,3 @@ const openapiDoc: OpenAPIV3.Document = { | ||
}, | ||
paths: { | ||
...prefix('/api', myRouteDefs.paths), | ||
}, | ||
...openapi, | ||
} | ||
@@ -109,5 +115,2 @@ | ||
The `prefix` function is used to move OpenAPI path definitions to a different | ||
prefix, because the `myRoutes` are served from the `/api` prefix. | ||
## CLI | ||
@@ -121,10 +124,7 @@ | ||
For each input file `file.ts`, writes a `file.openapi.ts` or | ||
`file.openapi.json`, depending on `--format`. | ||
Options: | ||
`--format` | ||
`-o OUTFILE`, `--outfile OUTFILE` | ||
Output file format. Either `ts` or `json`. Default: `ts`. | ||
Output file name. Must end in either `.ts` or `.json`. | ||
@@ -137,4 +137,4 @@ `--prettify`, `-p` | ||
Check that generated files are up-to-date without actually generating them. If | ||
any file is outdated, print an error and exit with status 1. Useful for CI. | ||
Check that the output file is up-to-date without actually writing it. If the | ||
file is outdated, print an error and exit with status 1. Useful for CI. | ||
@@ -219,2 +219,66 @@ ## How it works? | ||
## Reference | ||
Terms: | ||
- Route means an OpenAPI operation, i.e. an endpoint you can request. | ||
- `route` is the typera object that lets you create routes, see | ||
[the docs](https://akheron.github.io/typera/apiref/#route). | ||
- Route handler is the function passed to `.handler()` when defining a route | ||
- `request` is the sole parameter of the route handler function. | ||
- Route type is the type of the route variable returned by `route.get()` etc. | ||
For each route, typera-openapi determines the following information: | ||
| Information | Source | | ||
| ------------ | -------------------------------------------------- | | ||
| method | Which `route` method is called, e.g. `route.get()` | | ||
| path | The parameter of e.g. `route.get()` | | ||
| summary | JSDoc comment's `@summary` tag | | ||
| description | JSDoc comment's text | | ||
| tags | JSDoc comment's `@tags` | | ||
| parameters | See table below | | ||
| request body | See table below | | ||
| responses | See table below | | ||
Additionally, if the parent `router()` call has a `@prefix` tag in the JSDoc | ||
comment, it's prepended to the path of each of the routes. | ||
OpenAPI parameters covers all the other input expect the request body: | ||
| Parameter | Source | | ||
| --------- | ---------------------------------------------------------------------------------------------- | | ||
| path | [Route parameter captures](https://akheron.github.io/typera/apiref/#route-parameter-capturing) | | ||
| query | The type of `request.query` (the output of `Parser.query` or custom middleware) | | ||
| header | The type of `request.headers` (the output of `Parser.headers` or custom middleware) | | ||
| cookie | The type of `request.cookies` (the output of `Parser.cookies` or custom middleware) | | ||
OpenAPI allows different request body types per content type, but typera-openapi | ||
only allows one. | ||
| Body field | Source | | ||
| ------------ | ----------------------------------------------------------------------------- | | ||
| content type | `request.contentType`, or `'application/json'` if not defined | | ||
| schema | The type of `request.body` (the output of `Parser.body` or custom middleware) | | ||
A route can have multiple responses. These are modeled in the route type as | ||
`Route<Response1 | Response2 | ...>`. See | ||
[the docs](https://akheron.github.io/typera/apiref/#responses). Each response | ||
type adds a response to the OpenAPI document. | ||
| Response field | Source | | ||
| -------------- | ---------------------------------------------- | | ||
| status | Response's `Status` type (number literal type) | | ||
| description | JSDoc comment's `@response` tag | | ||
| content type | See below | | ||
| content schema | Response's `Body` type | | ||
| headers | Response's `Headers` type | | ||
Response's content type is determined as follows: | ||
- `text/plain` if the body type is string or number | ||
- `application/octet-stream` for | ||
[streaming responses](https://akheron.github.io/typera/apiref/#streaming-responses) | ||
- `application/json` otherwise | ||
## Releasing | ||
@@ -221,0 +285,0 @@ |
54150
16
977
289
+ Addedopenapi-types@10.0.0(transitive)
- Removedopenapi-types@9.3.1(transitive)
Updatedopenapi-types@^10.0.0