routing-controllers-openapi
Version:
Runtime OpenAPI v3 spec generation for routing-controllers
397 lines (358 loc) • 11.1 kB
text/typescript
// tslint:disable:no-submodule-imports
import _merge from 'lodash.merge'
import _capitalize from 'lodash.capitalize'
import _startCase from 'lodash.startcase'
import * as oa from 'openapi3-ts'
import * as pathToRegexp from 'path-to-regexp'
import 'reflect-metadata'
import { ParamMetadataArgs } from 'routing-controllers/types/metadata/args/ParamMetadataArgs'
import { applyOpenAPIDecorator } from './decorators'
import { IRoute } from './index'
/** Return full Express path of given route. */
export function getFullExpressPath(route: IRoute): string {
const { action, controller, options } = route
return (
(options.routePrefix || '') +
(controller.route || '') +
(action.route || '')
)
}
/**
* Return full OpenAPI-formatted path of given route.
*/
export function getFullPath(route: IRoute): string {
return expressToOpenAPIPath(getFullExpressPath(route))
}
/**
* Return OpenAPI Operation object for given route.
*/
export function getOperation(
route: IRoute,
schemas: { [p: string]: oa.SchemaObject | oa.ReferenceObject }
): oa.OperationObject {
const operation: oa.OperationObject = {
operationId: getOperationId(route),
parameters: [
...getHeaderParams(route),
...getPathParams(route),
...getQueryParams(route, schemas),
],
requestBody: getRequestBody(route) || undefined,
responses: getResponses(route),
summary: getSummary(route),
tags: getTags(route),
}
const cleanedOperation = Object.entries(operation)
.filter(
([_, value]) => value && (value.length || Object.keys(value).length)
)
.reduce((acc, [key, value]) => {
acc[key as keyof oa.OperationObject] = value
return acc
}, {} as unknown as oa.OperationObject)
return applyOpenAPIDecorator(cleanedOperation, route)
}
/**
* Return OpenAPI Operation ID for given route.
*/
export function getOperationId(route: IRoute): string {
return `${route.action.target.name}.${route.action.method}`
}
/**
* Return OpenAPI Paths Object for given routes
*/
export function getPaths(
routes: IRoute[],
schemas: { [p: string]: oa.SchemaObject | oa.ReferenceObject }
): oa.PathObject {
const routePaths = routes.map((route) => ({
[getFullPath(route)]: {
[route.action.type]: getOperation(route, schemas),
},
}))
// @ts-ignore: array spread
return _merge(...routePaths)
}
/**
* Return header parameters of given route.
*/
export function getHeaderParams(route: IRoute): oa.ParameterObject[] {
const headers: oa.ParameterObject[] = route.params
.filter((p) => p.type === 'header')
.map((headerMeta) => {
const schema = getParamSchema(headerMeta) as oa.SchemaObject
return {
in: 'header' as oa.ParameterLocation,
name: headerMeta.name || '',
required: isRequired(headerMeta, route),
schema,
}
})
const headersMeta = route.params.find((p) => p.type === 'headers')
if (headersMeta) {
const schema = getParamSchema(headersMeta) as oa.ReferenceObject
headers.push({
in: 'header',
name: schema.$ref.split('/').pop() || '',
required: isRequired(headersMeta, route),
schema,
})
}
return headers
}
/**
* Return path parameters of given route.
*
* Path parameters are first parsed from the path string itself, and then
* supplemented with possible @Param() decorator values.
*/
export function getPathParams(route: IRoute): oa.ParameterObject[] {
const path = getFullExpressPath(route)
const tokens = pathToRegexp.parse(path)
return tokens
.filter((token) => token && typeof token === 'object') // Omit non-parameter plain string tokens
.map((token: pathToRegexp.Key) => {
const name = token.name + ''
const param: oa.ParameterObject = {
in: 'path',
name,
required: token.modifier !== '?',
schema: { type: 'string' },
}
if (token.pattern && token.pattern !== '[^\\/]+?') {
param.schema = { pattern: token.pattern, type: 'string' }
}
const meta = route.params.find(
(p) => p.name === name && p.type === 'param'
)
if (meta) {
const metaSchema = getParamSchema(meta)
param.schema =
'type' in metaSchema ? { ...param.schema, ...metaSchema } : metaSchema
}
return param
})
}
/**
* Return query parameters of given route.
*/
export function getQueryParams(
route: IRoute,
schemas: { [p: string]: oa.SchemaObject | oa.ReferenceObject }
): oa.ParameterObject[] {
const queries: oa.ParameterObject[] = route.params
.filter((p) => p.type === 'query')
.map((queryMeta) => {
const schema = getParamSchema(queryMeta) as oa.SchemaObject
return {
in: 'query' as oa.ParameterLocation,
name: queryMeta.name || '',
required: isRequired(queryMeta, route),
schema,
}
})
const queriesMeta = route.params.find((p) => p.type === 'queries')
if (queriesMeta) {
const paramSchema = getParamSchema(queriesMeta) as oa.ReferenceObject
// the last segment after '/'
const paramSchemaName = paramSchema.$ref.split('/').pop() || ''
const currentSchema = schemas[paramSchemaName]
if (oa.isSchemaObject(currentSchema)) {
for (const [name, schema] of Object.entries(
currentSchema?.properties || {}
)) {
queries.push({
in: 'query',
name,
required: currentSchema.required?.includes(name),
schema,
})
}
}
}
return queries
}
function getNamedParamSchema(
param: ParamMetadataArgs
): oa.SchemaObject | oa.ReferenceObject {
const { type } = param
if (type === 'file') {
return { type: 'string', format: 'binary' }
}
if (type === 'files') {
return {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
}
}
return getParamSchema(param)
}
/**
* Return OpenAPI requestBody of given route, if it has one.
*/
export function getRequestBody(route: IRoute): oa.RequestBodyObject | void {
const bodyParamMetas = route.params.filter((d) => d.type === 'body-param')
const uploadFileMetas = route.params.filter((d) =>
['file', 'files'].includes(d.type)
)
const namedParamMetas = [...bodyParamMetas, ...uploadFileMetas]
const namedParamsSchema: oa.SchemaObject | null =
namedParamMetas.length > 0
? namedParamMetas.reduce(
(acc: oa.SchemaObject, d) => ({
...acc,
properties: {
...acc.properties,
[d.name!]: getNamedParamSchema(d),
},
required: isRequired(d, route)
? [...(acc.required || []), d.name!]
: acc.required,
}),
{ properties: {}, required: [], type: 'object' }
)
: null
const contentType =
uploadFileMetas.length > 0 ? 'multipart/form-data' : 'application/json'
const bodyMeta = route.params.find((d) => d.type === 'body')
if (bodyMeta) {
const bodySchema = getParamSchema(bodyMeta)
const items =
'items' in bodySchema && bodySchema.items ? bodySchema.items : bodySchema
const $ref = oa.isReferenceObject(items) ? items.$ref : ''
return {
content: {
[contentType]: {
schema: namedParamsSchema
? { allOf: [bodySchema, namedParamsSchema] }
: bodySchema,
},
},
description: ($ref || '').split('/').pop(),
required: isRequired(bodyMeta, route),
}
} else if (namedParamsSchema) {
return {
content: { [contentType]: { schema: namedParamsSchema } },
}
}
}
/**
* Return the content type of given route.
*/
export function getContentType(route: IRoute): string {
const defaultContentType =
route.controller.type === 'json'
? 'application/json'
: 'text/html; charset=utf-8'
const contentMeta = route.responseHandlers.find(
(h) => h.type === 'content-type'
)
return contentMeta ? contentMeta.value : defaultContentType
}
/**
* Return the status code of given route.
*/
export function getStatusCode(route: IRoute): string {
const successMeta = route.responseHandlers.find(
(h) => h.type === 'success-code'
)
return successMeta ? successMeta.value + '' : '200'
}
/**
* Return OpenAPI Responses object of given route.
*/
export function getResponses(route: IRoute): oa.ResponsesObject {
const contentType = getContentType(route)
const successStatus = getStatusCode(route)
return {
[successStatus]: {
content: { [contentType]: {} },
description: 'Successful response',
},
}
}
/**
* Return OpenAPI specification for given routes.
*/
export function getSpec(
routes: IRoute[],
schemas: { [p: string]: oa.SchemaObject | oa.ReferenceObject }
): oa.OpenAPIObject {
return {
components: { schemas: {} },
info: { title: '', version: '1.0.0' },
openapi: '3.0.0',
paths: getPaths(routes, schemas),
}
}
/**
* Return OpenAPI Operation summary string for given route.
*/
export function getSummary(route: IRoute): string {
return _capitalize(_startCase(route.action.method))
}
/**
* Return OpenAPI tags for given route.
*/
export function getTags(route: IRoute): string[] {
return [_startCase(route.controller.target.name.replace(/Controller$/, ''))]
}
/**
* Convert an Express path into an OpenAPI-compatible path.
*/
export function expressToOpenAPIPath(expressPath: string) {
const tokens = pathToRegexp.parse(expressPath)
return tokens
.map((d) => (typeof d === 'string' ? d : `${d.prefix}{${d.name}}`))
.join('')
}
/**
* Return true if given metadata argument is required, checking for global
* setting if local setting is not defined.
*/
function isRequired(meta: { required?: boolean }, route: IRoute) {
const globalRequired = route.options?.defaults?.paramOptions?.required
return globalRequired ? meta.required !== false : !!meta.required
}
/**
* Parse given parameter's OpenAPI Schema or Reference object using metadata
* reflection.
*/
function getParamSchema(
param: ParamMetadataArgs
): oa.SchemaObject | oa.ReferenceObject {
const { explicitType, index, object, method } = param
const type: (() => unknown) | unknown = Reflect.getMetadata(
'design:paramtypes',
object,
method
)[index]
if (typeof type === 'function' && type.name === 'Array') {
const items = explicitType
? { $ref: '#/components/schemas/' + explicitType.name }
: { type: 'object' as const }
return { items, type: 'array' }
}
if (explicitType) {
return { $ref: '#/components/schemas/' + explicitType.name }
}
if (typeof type === 'function') {
if (
type.prototype === String.prototype ||
type.prototype === Symbol.prototype
) {
return { type: 'string' }
} else if (type.prototype === Number.prototype) {
return { type: 'number' }
} else if (type.prototype === Boolean.prototype) {
return { type: 'boolean' }
} else if (type.name !== 'Object') {
return { $ref: '#/components/schemas/' + type.name }
}
}
return {}
}