From 3b8f7e3e0b65fdef085b7b3d228326cc67da1487 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 14 May 2025 15:21:12 +0530 Subject: [PATCH 1/3] feat: add support for parsing @Params() decorator --- __tests__/parameters.test.ts | 100 +++++++++++++++++++++++++++++++++-- src/generateSpec.ts | 32 +++++++++-- 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/__tests__/parameters.test.ts b/__tests__/parameters.test.ts index fe5e77f..397290f 100644 --- a/__tests__/parameters.test.ts +++ b/__tests__/parameters.test.ts @@ -5,6 +5,7 @@ import { HeaderParams, JsonController, Param, + Params, QueryParam, QueryParams, } from 'routing-controllers' @@ -17,8 +18,17 @@ import { parseRoutes, } from '../src' import { SchemaObject } from 'openapi3-ts' -import { validationMetadatasToSchemas } from 'class-validator-jsonschema' -import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator' +import { + JSONSchema, + validationMetadatasToSchemas, +} from 'class-validator-jsonschema' +import { + IsBoolean, + IsMongoId, + IsNumber, + IsOptional, + IsString, +} from 'class-validator' const { defaultMetadataStorage } = require('class-transformer/cjs/storage') describe('parameters', () => { @@ -40,6 +50,27 @@ describe('parameters', () => { types: string[] } + class ListUserParams { + @IsMongoId() + @IsString() + @JSONSchema({ + description: 'ID of the user', + example: '60d5ec49b3f1c8e4a8f8b8c1', + type: 'string', + format: 'Mongo ObjectId', + }) + id: string + + @IsString() + @IsOptional() + @JSONSchema({ + description: 'Name of the user', + example: 'John Doe', + type: 'string', + }) + name: string + } + @JsonController('/users') // @ts-ignore: not referenced class UsersController { @@ -52,6 +83,7 @@ describe('parameters', () => { @QueryParam('limit') _limit: number, @HeaderParam('Authorization', { required: true }) _authorization: string, + @Params() _params: ListUserParams, @QueryParams() _queryRef?: ListUsersQueryParams, @HeaderParams() _headerParams?: ListUsersHeaderParams ) { @@ -67,7 +99,7 @@ describe('parameters', () => { }) it('parses path parameter from path strings', () => { - expect(getPathParams({ ...route, params: [] })).toEqual([ + expect(getPathParams({ ...route, params: [] }, schemas)).toEqual([ { in: 'path', name: 'string', @@ -108,7 +140,51 @@ describe('parameters', () => { }) it('supplements path parameter with @Param decorator', () => { - expect(getPathParams(route)).toEqual([ + expect(getPathParams(route, schemas)).toEqual( + expect.arrayContaining([ + { + in: 'path', + name: 'string', + required: true, + schema: { pattern: '[^\\/#\\?]+?', type: 'string' }, + }, + { + in: 'path', + name: 'regex', + required: true, + schema: { pattern: '\\d{6}', type: 'string' }, + }, + { + in: 'path', + name: 'optional', + required: false, + schema: { pattern: '[^\\/#\\?]+?', type: 'string' }, + }, + { + in: 'path', + name: 'number', + required: true, + schema: { pattern: '[^\\/#\\?]+?', type: 'number' }, + }, + { + in: 'path', + name: 'boolean', + required: true, + schema: { pattern: '[^\\/#\\?]+?', type: 'boolean' }, + }, + { + in: 'path', + name: 'any', + required: true, + schema: {}, + }, + ]) + ) + }) + + it('parses path param ref from @Params decorator', () => { + expect(getPathParams(route, schemas)).toEqual([ + // string comes from path string { in: 'path', name: 'string', @@ -145,11 +221,25 @@ describe('parameters', () => { required: true, schema: {}, }, + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'ID of the user', + example: '60d5ec49b3f1c8e4a8f8b8c1', + type: 'string', + format: 'Mongo ObjectId', + pattern: '^[0-9a-fA-F]{24}$', + }, + }, ]) }) it('ignores @Param if corresponding name is not found in path string', () => { - expect(getPathParams(route).filter((r) => r.name === 'invalid')).toEqual([]) + expect( + getPathParams(route, schemas).filter((r) => r.name === 'invalid') + ).toEqual([]) }) it('parses query param from @QueryParam decorator', () => { diff --git a/src/generateSpec.ts b/src/generateSpec.ts index e74f9b9..92e5dcd 100644 --- a/src/generateSpec.ts +++ b/src/generateSpec.ts @@ -38,7 +38,7 @@ export function getOperation( operationId: getOperationId(route), parameters: [ ...getHeaderParams(route), - ...getPathParams(route), + ...getPathParams(route, schemas), ...getQueryParams(route, schemas), ], requestBody: getRequestBody(route) || undefined, @@ -119,11 +119,14 @@ 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.ReferenceObject } +): oa.ParameterObject[] { const path = getFullExpressPath(route) const tokens = pathToRegexp.parse(path) - return tokens + const params: oa.ParameterObject[] = tokens .filter((token) => token && typeof token === 'object') // Omit non-parameter plain string tokens .map((token: pathToRegexp.Key) => { const name = token.name + '' @@ -149,6 +152,29 @@ export function getPathParams(route: IRoute): oa.ParameterObject[] { return param }) + + const paramsMeta = route.params.find((p) => p.type === 'params') + if (paramsMeta) { + const paramSchema = getParamSchema(paramsMeta) 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 || {} + )) { + params.push({ + in: 'path', + name, + required: currentSchema.required?.includes(name), + schema, + }) + } + } + } + + return params } /** From 2bbec26fc91819678e479cae489475149c350ca7 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 14 May 2025 15:28:07 +0530 Subject: [PATCH 2/3] tests: add optional 'name' path parameter with description and example --- __tests__/parameters.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/__tests__/parameters.test.ts b/__tests__/parameters.test.ts index 397290f..3d76f70 100644 --- a/__tests__/parameters.test.ts +++ b/__tests__/parameters.test.ts @@ -233,6 +233,16 @@ describe('parameters', () => { pattern: '^[0-9a-fA-F]{24}$', }, }, + { + in: 'path', + name: 'name', + required: false, + schema: { + description: 'Name of the user', + example: 'John Doe', + type: 'string', + }, + }, ]) }) From 4155b1ceca8e898f72e19c7deac9aa159c23319b Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 14 May 2025 16:41:12 +0530 Subject: [PATCH 3/3] fix: resolve the issue where params appear twice in the spec --- __tests__/parameters.test.ts | 16 +++++++++++++++- src/generateSpec.ts | 19 +++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/__tests__/parameters.test.ts b/__tests__/parameters.test.ts index 3d76f70..a7d2716 100644 --- a/__tests__/parameters.test.ts +++ b/__tests__/parameters.test.ts @@ -74,7 +74,9 @@ describe('parameters', () => { @JsonController('/users') // @ts-ignore: not referenced class UsersController { - @Get('/:string/:regex(\\d{6})/:optional?/:number/:boolean/:any') + @Get( + '/:string/:regex(\\d{6})/:optional?/:number/:boolean/:any/:id/:name?' + ) getPost( @Param('number') _numberParam: number, @Param('invalid') _invalidParam: string, @@ -136,6 +138,18 @@ describe('parameters', () => { required: true, schema: { pattern: '[^\\/#\\?]+?', type: 'string' }, }, + { + in: 'path', + name: 'id', + required: true, + schema: { pattern: '[^\\/#\\?]+?', type: 'string' }, + }, + { + in: 'path', + name: 'name', + required: false, + schema: { pattern: '[^\\/#\\?]+?', type: 'string' }, + }, ]) }) diff --git a/src/generateSpec.ts b/src/generateSpec.ts index 92e5dcd..1712c99 100644 --- a/src/generateSpec.ts +++ b/src/generateSpec.ts @@ -164,12 +164,19 @@ export function getPathParams( for (const [name, schema] of Object.entries( currentSchema?.properties || {} )) { - params.push({ - in: 'path', - name, - required: currentSchema.required?.includes(name), - schema, - }) + // Check if the parameter name is already in the params array. + const existingParam = params.find((param) => param.name === name) + if (existingParam) { + // remove and replace with more specific schema + params.splice(params.indexOf(existingParam), 1) + params.push({ + in: 'path', + name, + required: currentSchema.required?.includes(name), + schema, + }) + continue + } } } }