Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions e2e/src/cats/cats.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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',
Expand Down
16 changes: 15 additions & 1 deletion e2e/validate-schema.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 38 additions & 1 deletion lib/services/schema-object-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
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(
Expand Down
79 changes: 79 additions & 0 deletions test/services/schema-object-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SchemasObject> = {};

// 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<string, SchemasObject> = {};

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 = {
Expand Down