diff --git a/e2e/src/cats/cats.controller.ts b/e2e/src/cats/cats.controller.ts index ab99788d4..d4fc880e0 100644 --- a/e2e/src/cats/cats.controller.ts +++ b/e2e/src/cats/cats.controller.ts @@ -27,6 +27,7 @@ import { CatsService } from './cats.service'; import { Cat } from './classes/cat.class'; import { CreateCatDto } from './dto/create-cat.dto'; import { LettersEnum, PaginationQuery } from './dto/pagination-query.dto'; +import { TagDto } from './dto/tag.dto'; import { CatBreed } from './enums/cat-breed.enum'; @ApiSecurity('basic') @@ -173,6 +174,14 @@ export class CatsController { }) getWithEnumNamedParam(@Param('type') type: LettersEnum) {} + @Get('with-named-type-example') + @ApiQuery({ + name: 'filter', + type: TagDto, + example: 'example-tag' + }) + getWithNamedTypeExample(@Query('filter') filter: TagDto) {} + @Get('with-random-query') @ApiQuery({ name: 'type', diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts index c58026706..4d5ef7865 100644 --- a/e2e/validate-schema.e2e-spec.ts +++ b/e2e/validate-schema.e2e-spec.ts @@ -10,7 +10,7 @@ import { OpenAPIObject, SwaggerModule } from '../lib'; -import { SchemaObject } from '../lib/interfaces/open-api-spec.interface'; +import { ParameterObject, SchemaObject } from '../lib/interfaces/open-api-spec.interface'; import { ApplicationModule } from './src/app.module'; import { Cat } from './src/cats/classes/cat.class'; import { TagDto } from './src/cats/dto/tag.dto'; @@ -191,6 +191,20 @@ describe('Validate OpenAPI schema', () => { } }); + it('should preserve example metadata for named type query params (issue #3335)', () => { + const document = SwaggerModule.createDocument(app, options); + const params = + document.paths['/api/cats/with-named-type-example']['get']['parameters']; + const filterParam = params.find( + (p: any) => p.name === 'filter' && p.in === 'query' + ); + expect(filterParam).toBeDefined(); + expect((filterParam as ParameterObject).schema).toEqual({ + example: 'example-tag', + allOf: [{ $ref: '#/components/schemas/TagDto' }] + }); + }); + it('should fix colons in url', async () => { const document = SwaggerModule.createDocument(app, options); expect( diff --git a/lib/services/schema-object-factory.ts b/lib/services/schema-object-factory.ts index 3b94b61d8..20c964ef2 100644 --- a/lib/services/schema-object-factory.ts +++ b/lib/services/schema-object-factory.ts @@ -123,7 +123,44 @@ export class SchemaObjectFactory { if (param.name) { // We should not spread parameters that have a name // Just generate the schema for the type instead and link it with ref if needed - return this.getCustomType(param, schemas); + const customType = this.getCustomType(param, schemas); + + // Move schema-level options (e.g., example) from the top level into the schema + // object so they are not lost when mapParamTypes strips top-level keys. + const schemaOptionsKeys = + this.swaggerTypesMapper.getSchemaOptionsKeys(); + const schemaOptionsFromParam: Record = {}; + for (const key of schemaOptionsKeys) { + // Skip 'type' and 'items' as they are handled by getCustomType + if (key === 'type' || key === 'items') { + continue; + } + if (key in customType && !(key in (customType.schema || {}))) { + schemaOptionsFromParam[key] = (customType as any)[key]; + delete (customType as any)[key]; + } + } + if (Object.keys(schemaOptionsFromParam).length > 0) { + const existingSchema = (customType.schema || {}) as Record< + string, + any + >; + // When we have extra metadata alongside a $ref, use allOf pattern + if ('$ref' in existingSchema) { + const { $ref, ...restSchema } = existingSchema; + (customType as any).schema = { + ...restSchema, + ...schemaOptionsFromParam, + allOf: [{ $ref: $ref as string }] + }; + } else { + (customType as any).schema = { + ...existingSchema, + ...schemaOptionsFromParam + }; + } + } + return customType; } const propertiesWithType = this.extractPropertiesFromType( diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts index 371a8713d..8fb6ab29c 100644 --- a/test/services/schema-object-factory.spec.ts +++ b/test/services/schema-object-factory.spec.ts @@ -748,6 +748,85 @@ describe('SchemaObjectFactory', () => { }); }); + describe('createFromModel', () => { + it('should preserve parent example when a non-body param property has a DTO type', () => { + class ChildDto { + @ApiProperty({ example: 'child DTO example 1' }) + childKey1: string; + @ApiProperty({ example: 'child DTO example 2' }) + childKey2: string; + } + + class ParentDto { + @ApiProperty({ example: 'parent DTO example' }) + parentKey: ChildDto; + } + + const schemas: Record = {}; + + // Simulate what ParametersMetadataMapper produces for @Query() ParentDto: + // it expands the DTO into individual properties with their metadata + const queryParams: ParamWithTypeMetadata[] = [ + { + in: 'query', + type: ChildDto, + name: 'parentKey', + required: true, + example: 'parent DTO example' + } as any + ]; + + const result = schemaObjectFactory.createFromModel( + queryParams, + schemas + ); + + expect(result).toHaveLength(1); + const paramResult = result[0] as any; + expect(paramResult.name).toBe('parentKey'); + // The parent's example should be preserved in the schema + expect(paramResult.schema).toBeDefined(); + expect(paramResult.schema.example).toBe('parent DTO example'); + // Should use allOf pattern when extra metadata exists alongside $ref + expect(paramResult.schema.allOf).toBeDefined(); + expect(paramResult.schema.allOf[0].$ref).toContain('ChildDto'); + }); + + it('should not alter schema when no extra schema options exist on param', () => { + class SimpleChild { + @ApiProperty() + value: string; + } + + class SimpleParent { + @ApiProperty() + child: SimpleChild; + } + + const schemas: Record = {}; + + const queryParams: ParamWithTypeMetadata[] = [ + { + in: 'query', + type: SimpleChild, + name: 'child', + required: true + } as any + ]; + + const result = schemaObjectFactory.createFromModel( + queryParams, + schemas + ); + + expect(result).toHaveLength(1); + const paramResult = result[0] as any; + // Without extra schema options, should use plain $ref + expect(paramResult.schema.$ref).toContain('SimpleChild'); + expect(paramResult.schema.allOf).toBeUndefined(); + }); + }); + describe('transformToArraySchemaProperty', () => { it('should preserve items schema when metadata.items is already defined and type is string', () => { const metadata = {