diff --git a/__tests__/fixtures/controllers.ts b/__tests__/fixtures/controllers.ts index f6a7452..81a7594 100644 --- a/__tests__/fixtures/controllers.ts +++ b/__tests__/fixtures/controllers.ts @@ -34,11 +34,13 @@ export class CreateNestedBody { users: CreateUserBody[] } -export class CreatePostBody { +class CreatePostBodyBase { @IsString({ each: true }) content: string[] } +export class CreatePostBody extends CreatePostBodyBase {} + export class ListUsersQueryParams { @IsOptional() @IsEmail() @@ -130,7 +132,7 @@ export class UsersController { @Post('/:userId/posts') createUserPost( - @Body({ required: true }) _body: CreatePostBody, + @Body({ required: true, type: CreatePostBody }) _body: CreatePostBody, @BodyParam('token') _token: string ) { return diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e03b01c..5b97d58 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,28 +1,25 @@ // tslint:disable:no-implicit-dependencies no-submodule-imports -const { defaultMetadataStorage } = require('class-transformer/cjs/storage') -import { validationMetadatasToSchemas } from 'class-validator-jsonschema' +import * as fs from 'fs' +import { targetConstructorToSchema, validationMetadatasToSchemas } from 'class-validator-jsonschema' import _merge from 'lodash.merge' import { getMetadataArgsStorage } from 'routing-controllers' -import { - expressToOpenAPIPath, - getFullPath, - getOperationId, - parseRoutes, - routingControllersToSpec, -} from '../src' +import { expressToOpenAPIPath, getFullPath, getOperationId, parseRoutes, routingControllersToSpec } from '../src' import { getRequestBody } from '../src/generateSpec' import { + CreatePostBody, RootController, UserPostsController, - UsersController, + UsersController } from './fixtures/controllers' +const { defaultMetadataStorage } = require('class-transformer/cjs/storage') + // Construct OpenAPI spec: const storage = getMetadataArgsStorage() const options = { controllers: [UsersController, UserPostsController], - routePrefix: '/api', + routePrefix: '/api' } const routes = parseRoutes(storage, options) @@ -31,7 +28,12 @@ describe('index', () => { // Include component schemas parsed with class-validator-jsonschema: const schemas = validationMetadatasToSchemas({ classTransformerMetadataStorage: defaultMetadataStorage, - refPointerPrefix: '#/components/schemas/', + refPointerPrefix: '#/components/schemas/' + }) + + schemas.CreatePostBody = targetConstructorToSchema(CreatePostBody, { + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/' }) const spec = routingControllersToSpec(storage, options, { @@ -40,16 +42,17 @@ describe('index', () => { securitySchemes: { basicAuth: { scheme: 'basic', - type: 'http', + type: 'http' }, bearerAuth: { scheme: 'bearer', - type: 'http', - }, - }, + type: 'http' + } + } }, - info: { title: 'My app', version: '1.2.0' }, + info: { title: 'My app', version: '1.2.0' } }) + fs.writeFileSync('spec.test.json', JSON.stringify(spec, null, 2)) expect(spec).toEqual(require('./fixtures/spec.json')) }) @@ -60,86 +63,86 @@ describe('index', () => { method: 'listUsers', route: '/', target: UsersController, - type: 'get', + type: 'get' }, { method: 'listUsersInRange', route: '/:from-:to', target: UsersController, - type: 'get', + type: 'get' }, { method: 'getUser', route: '/:userId?', target: UsersController, - type: 'get', + type: 'get' }, { method: 'createUser', route: '/', target: UsersController, - type: 'post', + type: 'post' }, { method: 'createUserWithType', route: '/withType', target: UsersController, - type: 'post', + type: 'post' }, { method: 'createManyUsers', route: '/', target: UsersController, - type: 'put', + type: 'put' }, { method: 'createNestedUsers', route: '/nested', target: UsersController, - type: 'post', + type: 'post' }, { method: 'createUserPost', route: '/:userId/posts', target: UsersController, - type: 'post', + type: 'post' }, { method: 'deleteUsersByVersion', route: '/:version(v?\\d{1}|all)', target: UsersController, - type: 'delete', + type: 'delete' }, { method: 'putUserDefault', route: undefined, target: UsersController, - type: 'put', + type: 'put' }, { method: 'getUserPost', route: '/:postId', target: UserPostsController, - type: 'get', + type: 'get' }, { method: 'patchUserPost', route: '/:postId', target: UserPostsController, - type: 'patch', + type: 'patch' }, { method: 'getDefaultPath', route: undefined, target: RootController, - type: 'get', + type: 'get' }, { method: 'getStringPath', route: '/stringPath', target: RootController, - type: 'get', - }, + type: 'get' + } ]) }) @@ -181,7 +184,8 @@ describe('index', () => { const route = _merge({}, routes[0]) expect(getOperationId(route)).toEqual('UsersController.listUsers') - route.action.target = class AnotherController {} + route.action.target = class AnotherController { + } route.action.method = 'anotherMethod' expect(getOperationId(route)).toEqual('AnotherController.anotherMethod') }) @@ -189,112 +193,132 @@ describe('index', () => { describe('getRequestBody', () => { it('parse a single `body` metadata item into a single `object` schema', () => { + const schemas = validationMetadatasToSchemas({ + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/' + }) const route = routes.find((d) => d.action.method === 'createUser')! expect(route).toBeDefined() - expect(getRequestBody(route)).toEqual({ + expect(getRequestBody(route, schemas)).toEqual({ content: { 'application/json': { schema: { - $ref: '#/components/schemas/CreateUserBody', - }, - }, + $ref: '#/components/schemas/CreateUserBody' + } + } }, description: 'CreateUserBody', - required: false, + required: false }) }) it('parse a single `body` metadata item of array type into a single `object` schema', () => { const route = routes.find((d) => d.action.method === 'createManyUsers')! + const schemas = validationMetadatasToSchemas({ + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/' + }) expect(route).toBeDefined() - expect(getRequestBody(route)).toEqual({ + expect(getRequestBody(route, schemas)).toEqual({ content: { 'application/json': { schema: { items: { - $ref: '#/components/schemas/CreateUserBody', + $ref: '#/components/schemas/CreateUserBody' }, - type: 'array', - }, - }, + type: 'array' + } + } }, description: 'CreateUserBody', - required: true, + required: true }) }) it('parse a single `body-param` metadata item into a single `object` schema', () => { + const schemas = validationMetadatasToSchemas({ + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/' + }) const route = routes.find((d) => d.action.method === 'patchUserPost')! expect(route).toBeDefined() - expect(getRequestBody(route)).toEqual({ + expect(getRequestBody(route, schemas)).toEqual({ content: { 'application/json': { schema: { properties: { token: { - type: 'string', - }, + type: 'string' + } }, required: [], - type: 'object', - }, - }, - }, + type: 'object' + } + } + } }) }) it('combine multiple `body-param` metadata items into a single `object` schema', () => { + const schemas = validationMetadatasToSchemas({ + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/' + }) const route = routes.find((d) => d.action.method === 'putUserDefault')! expect(route).toBeDefined() - expect(getRequestBody(route)).toEqual({ + expect(getRequestBody(route, schemas)).toEqual({ content: { 'application/json': { schema: { properties: { limit: { - type: 'number', + type: 'number' }, query: { - $ref: '#/components/schemas/UserQuery', + $ref: '#/components/schemas/UserQuery' }, token: { - type: 'string', - }, + type: 'string' + } }, required: ['token'], - type: 'object', - }, - }, - }, + type: 'object' + } + } + } }) }) it('wrap `body` and `body-param` metadata items under a single `allOf` schema', () => { + const schemas = validationMetadatasToSchemas({ + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/' + }) const route = routes.find((d) => d.action.method === 'createUserPost')! expect(route).toBeDefined() - expect(getRequestBody(route)).toEqual({ + expect(getRequestBody(route, schemas)).toEqual({ content: { 'application/json': { schema: { allOf: [ { - $ref: '#/components/schemas/CreatePostBody', + $ref: '#/components/schemas/CreatePostBody' }, { properties: { token: { - type: 'string', - }, + type: 'string' + } }, required: [], - type: 'object', - }, - ], - }, - }, + type: 'object' + } + ] + } + } }, description: 'CreatePostBody', - required: true, + required: true }) }) }) diff --git a/package-lock.json b/package-lock.json index 4df533c..b93ab88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,8 @@ "@types/rimraf": "^3.0.0", "@types/validator": "^13.7.10", "class-transformer": "^0.5.1", - "class-validator": "^0.13.2", - "class-validator-jsonschema": "^3.1.1", + "class-validator": "^0.14.1", + "class-validator-jsonschema": "^5.0.1", "codecov": "^3.8.1", "jest": "^29.3.1", "prettier": "^2.8.0", @@ -1254,10 +1254,9 @@ "dev": true }, "node_modules/@types/validator": { - "version": "13.7.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", - "integrity": "sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==", - "dev": true + "version": "13.11.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.10.tgz", + "integrity": "sha512-e2PNXoXLr6Z+dbfx5zSh9TRlXJrELycxiaXznp4S5+D2M3b9bqJEitNHA5923jhnB2zzFiZHa2f0SI1HoIahpg==" }, "node_modules/@types/yargs": { "version": "17.0.17", @@ -1740,29 +1739,51 @@ "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", - "integrity": "sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", "dependencies": { - "libphonenumber-js": "^1.9.43", - "validator": "^13.7.0" + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" } }, "node_modules/class-validator-jsonschema": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/class-validator-jsonschema/-/class-validator-jsonschema-3.1.1.tgz", - "integrity": "sha512-xga/5rTDKaYysivdX6OWaVllAS2OGeXgRRaXRo5QAW+mSDOpbjrf5JhmdPvUKMEkGyQer0gCoferB3COl170Rg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/class-validator-jsonschema/-/class-validator-jsonschema-5.0.1.tgz", + "integrity": "sha512-9uTdo5jSnJUj7f0dS8YZDqM0Fv1Uky0BWefswnNa2F4nRcKPCiEb5z3nDUaXyEzcERCrizE+0AGDSao1uSNX9g==", "dev": true, "dependencies": { "lodash.groupby": "^4.6.0", "lodash.merge": "^4.6.2", - "openapi3-ts": "^2.0.0", + "openapi3-ts": "^3.0.0", "reflect-metadata": "^0.1.13", - "tslib": "^2.0.3" + "tslib": "^2.4.1" }, "peerDependencies": { "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.1" + "class-validator": "^0.14.0" + } + }, + "node_modules/class-validator-jsonschema/node_modules/openapi3-ts": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-3.2.0.tgz", + "integrity": "sha512-/ykNWRV5Qs0Nwq7Pc0nJ78fgILvOT/60OxEmB3v7yQ8a8Bwcm43D4diaYazG/KBn6czA+52XYy931WFLMCUeSg==", + "dev": true, + "dependencies": { + "yaml": "^2.2.1" + } + }, + "node_modules/class-validator-jsonschema/node_modules/yaml": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.3.tgz", + "integrity": "sha512-sntgmxj8o7DE7g/Qi60cqpLBA3HG3STcDA0kO+WfB05jEKhZMbY7umNm2rBpQvsmZ16/lPXCJGW2672dgOUkrg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" } }, "node_modules/cliui": { @@ -3855,9 +3876,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.10.15", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.15.tgz", - "integrity": "sha512-sLeVLmWX17VCKKulc+aDIRHS95TxoTsKMRJi5s5gJdwlqNzMWcBCtSHHruVyXjqfi67daXM2SnLf2juSrdx5Sg==" + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.3.tgz", + "integrity": "sha512-RU0CTsLCu2v6VEzdP+W6UU2n5+jEpMDRkGxUeBgsAJgre3vKgm17eApISH9OQY4G0jZYJVIc8qXmz6CJFueAFg==" }, "node_modules/lines-and-columns": { "version": "1.2.4", @@ -5651,9 +5672,9 @@ } }, "node_modules/validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", "engines": { "node": ">= 0.10" } @@ -6781,10 +6802,9 @@ "dev": true }, "@types/validator": { - "version": "13.7.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", - "integrity": "sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==", - "dev": true + "version": "13.11.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.10.tgz", + "integrity": "sha512-e2PNXoXLr6Z+dbfx5zSh9TRlXJrELycxiaXznp4S5+D2M3b9bqJEitNHA5923jhnB2zzFiZHa2f0SI1HoIahpg==" }, "@types/yargs": { "version": "17.0.17", @@ -7152,25 +7172,43 @@ "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "class-validator": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", - "integrity": "sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", "requires": { - "libphonenumber-js": "^1.9.43", - "validator": "^13.7.0" + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" } }, "class-validator-jsonschema": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/class-validator-jsonschema/-/class-validator-jsonschema-3.1.1.tgz", - "integrity": "sha512-xga/5rTDKaYysivdX6OWaVllAS2OGeXgRRaXRo5QAW+mSDOpbjrf5JhmdPvUKMEkGyQer0gCoferB3COl170Rg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/class-validator-jsonschema/-/class-validator-jsonschema-5.0.1.tgz", + "integrity": "sha512-9uTdo5jSnJUj7f0dS8YZDqM0Fv1Uky0BWefswnNa2F4nRcKPCiEb5z3nDUaXyEzcERCrizE+0AGDSao1uSNX9g==", "dev": true, "requires": { "lodash.groupby": "^4.6.0", "lodash.merge": "^4.6.2", - "openapi3-ts": "^2.0.0", + "openapi3-ts": "^3.0.0", "reflect-metadata": "^0.1.13", - "tslib": "^2.0.3" + "tslib": "^2.4.1" + }, + "dependencies": { + "openapi3-ts": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-3.2.0.tgz", + "integrity": "sha512-/ykNWRV5Qs0Nwq7Pc0nJ78fgILvOT/60OxEmB3v7yQ8a8Bwcm43D4diaYazG/KBn6czA+52XYy931WFLMCUeSg==", + "dev": true, + "requires": { + "yaml": "^2.2.1" + } + }, + "yaml": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.3.tgz", + "integrity": "sha512-sntgmxj8o7DE7g/Qi60cqpLBA3HG3STcDA0kO+WfB05jEKhZMbY7umNm2rBpQvsmZ16/lPXCJGW2672dgOUkrg==", + "dev": true + } } }, "cliui": { @@ -8788,9 +8826,9 @@ "dev": true }, "libphonenumber-js": { - "version": "1.10.15", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.15.tgz", - "integrity": "sha512-sLeVLmWX17VCKKulc+aDIRHS95TxoTsKMRJi5s5gJdwlqNzMWcBCtSHHruVyXjqfi67daXM2SnLf2juSrdx5Sg==" + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.3.tgz", + "integrity": "sha512-RU0CTsLCu2v6VEzdP+W6UU2n5+jEpMDRkGxUeBgsAJgre3vKgm17eApISH9OQY4G0jZYJVIc8qXmz6CJFueAFg==" }, "lines-and-columns": { "version": "1.2.4", @@ -10165,9 +10203,9 @@ } }, "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index 2558ade..9a484f3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "singleQuote": true }, "dependencies": { + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "class-validator-jsonschema": "^5.0.1", "lodash.capitalize": "^4.2.1", "lodash.merge": "^4.6.2", "lodash.startcase": "^4.4.0", @@ -47,9 +50,6 @@ "@types/reflect-metadata": "^0.1.0", "@types/rimraf": "^3.0.0", "@types/validator": "^13.7.10", - "class-transformer": "^0.5.1", - "class-validator": "^0.13.2", - "class-validator-jsonschema": "^3.1.1", "codecov": "^3.8.1", "jest": "^29.3.1", "prettier": "^2.8.0", diff --git a/src/generateSpec.ts b/src/generateSpec.ts index 3ef1463..3f338c3 100644 --- a/src/generateSpec.ts +++ b/src/generateSpec.ts @@ -9,6 +9,9 @@ import { ParamMetadataArgs } from 'routing-controllers/types/metadata/args/Param import { applyOpenAPIDecorator } from './decorators' import { IRoute } from './index' +import { targetConstructorToSchema } from 'class-validator-jsonschema' + +const { defaultMetadataStorage } = require('class-transformer/cjs/storage') /** Return full Express path of given route. */ export function getFullExpressPath(route: IRoute): string { @@ -37,11 +40,11 @@ export function getOperation( const operation: oa.OperationObject = { operationId: getOperationId(route), parameters: [ - ...getHeaderParams(route), - ...getPathParams(route), + ...getHeaderParams(route, schemas), + ...getPathParams(route, schemas), ...getQueryParams(route, schemas), ], - requestBody: getRequestBody(route) || undefined, + requestBody: getRequestBody(route, schemas) || undefined, responses: getResponses(route), summary: getSummary(route), tags: getTags(route), @@ -86,11 +89,14 @@ export function getPaths( /** * Return header parameters of given route. */ -export function getHeaderParams(route: IRoute): oa.ParameterObject[] { +export function getHeaderParams( + route: IRoute, + schemas: { [p: string]: oa.SchemaObject } +): oa.ParameterObject[] { const headers: oa.ParameterObject[] = route.params .filter((p) => p.type === 'header') .map((headerMeta) => { - const schema = getParamSchema(headerMeta) as oa.SchemaObject + const schema = getParamSchema(headerMeta, schemas) as oa.SchemaObject return { in: 'header' as oa.ParameterLocation, name: headerMeta.name || '', @@ -101,7 +107,7 @@ export function getHeaderParams(route: IRoute): oa.ParameterObject[] { const headersMeta = route.params.find((p) => p.type === 'headers') if (headersMeta) { - const schema = getParamSchema(headersMeta) as oa.ReferenceObject + const schema = getParamSchema(headersMeta, schemas) as oa.ReferenceObject headers.push({ in: 'header', name: schema.$ref.split('/').pop() || '', @@ -119,7 +125,10 @@ export function getHeaderParams(route: IRoute): oa.ParameterObject[] { * 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[] { +export function getPathParams( + route: IRoute, + schemas: { [p: string]: oa.SchemaObject } +): oa.ParameterObject[] { const path = getFullExpressPath(route) const tokens = pathToRegexp.parse(path) @@ -142,7 +151,7 @@ export function getPathParams(route: IRoute): oa.ParameterObject[] { (p) => p.name === name && p.type === 'param' ) if (meta) { - const metaSchema = getParamSchema(meta) + const metaSchema = getParamSchema(meta, schemas) param.schema = 'type' in metaSchema ? { ...param.schema, ...metaSchema } : metaSchema } @@ -161,7 +170,7 @@ export function getQueryParams( const queries: oa.ParameterObject[] = route.params .filter((p) => p.type === 'query') .map((queryMeta) => { - const schema = getParamSchema(queryMeta) as oa.SchemaObject + const schema = getParamSchema(queryMeta, schemas) as oa.SchemaObject return { in: 'query' as oa.ParameterLocation, name: queryMeta.name || '', @@ -172,7 +181,10 @@ export function getQueryParams( const queriesMeta = route.params.find((p) => p.type === 'queries') if (queriesMeta) { - const paramSchema = getParamSchema(queriesMeta) as oa.ReferenceObject + const paramSchema = getParamSchema( + queriesMeta, + schemas + ) as oa.ReferenceObject // the last segment after '/' const paramSchemaName = paramSchema.$ref.split('/').pop() || '' const currentSchema = schemas[paramSchemaName] @@ -194,7 +206,10 @@ export function getQueryParams( /** * Return OpenAPI requestBody of given route, if it has one. */ -export function getRequestBody(route: IRoute): oa.RequestBodyObject | void { +export function getRequestBody( + route: IRoute, + schemas: { [p: string]: oa.SchemaObject } +): oa.RequestBodyObject | void { const bodyParamMetas = route.params.filter((d) => d.type === 'body-param') const bodyParamsSchema: oa.SchemaObject | null = bodyParamMetas.length > 0 @@ -203,7 +218,7 @@ export function getRequestBody(route: IRoute): oa.RequestBodyObject | void { ...acc, properties: { ...acc.properties, - [d.name!]: getParamSchema(d), + [d.name!]: getParamSchema(d, schemas), }, required: isRequired(d, route) ? [...(acc.required || []), d.name!] @@ -216,7 +231,7 @@ export function getRequestBody(route: IRoute): oa.RequestBodyObject | void { const bodyMeta = route.params.find((d) => d.type === 'body') if (bodyMeta) { - const bodySchema = getParamSchema(bodyMeta) + const bodySchema = getParamSchema(bodyMeta, schemas) const { $ref } = 'items' in bodySchema && bodySchema.items ? bodySchema.items : bodySchema @@ -330,7 +345,8 @@ function isRequired(meta: { required?: boolean }, route: IRoute) { * reflection. */ function getParamSchema( - param: ParamMetadataArgs + param: ParamMetadataArgs, + schemas: { [p: string]: oa.SchemaObject } ): oa.SchemaObject | oa.ReferenceObject { const { explicitType, index, object, method } = param @@ -343,9 +359,21 @@ function getParamSchema( const items = explicitType ? { $ref: '#/components/schemas/' + explicitType.name } : { type: 'object' as const } + if (!(explicitType.name in schemas)) { + schemas[explicitType.name] = targetConstructorToSchema(explicitType, { + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/', + }) as any + } return { items, type: 'array' } } if (explicitType) { + if (!(explicitType.name in schemas)) { + schemas[explicitType.name] = targetConstructorToSchema(explicitType, { + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/', + }) as any + } return { $ref: '#/components/schemas/' + explicitType.name } } if (typeof type === 'function') { @@ -359,6 +387,12 @@ function getParamSchema( } else if (type.prototype === Boolean.prototype) { return { type: 'boolean' } } else if (type.name !== 'Object') { + if (!(type.name in schemas)) { + schemas[type.name] = targetConstructorToSchema(type, { + classTransformerMetadataStorage: defaultMetadataStorage, + refPointerPrefix: '#/components/schemas/', + }) as any + } return { $ref: '#/components/schemas/' + type.name } } }