diff --git a/e2e/header-version.e2e-spec.ts b/e2e/header-version.e2e-spec.ts new file mode 100644 index 000000000..42a7b177b --- /dev/null +++ b/e2e/header-version.e2e-spec.ts @@ -0,0 +1,174 @@ +import { INestApplication, VERSION_NEUTRAL, VersioningType } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { + DocumentBuilder, + OpenAPIObject, + SwaggerModule +} from '../lib'; +import { ApplicationModule } from './src/dog-app.module'; + +describe('Validate header-based versioned OpenAPI schema', () => { + let app: INestApplication; + let options: Omit; + + beforeEach(async () => { + app = await NestFactory.create(ApplicationModule, { + logger: false + }); + app.setGlobalPrefix('api/'); + app.enableVersioning({ + type: VersioningType.HEADER, + header: 'x-api-version', + defaultVersion: VERSION_NEUTRAL + }); + + options = new DocumentBuilder() + .setTitle('Dogs example') + .setDescription('The dogs API description') + .setVersion('1.0') + .setBasePath('api') + .addTag('dogs') + .addBasicAuth() + .addBearerAuth() + .addOAuth2() + .addApiKey() + .addApiKey({ type: 'apiKey' }, 'key1') + .addApiKey({ type: 'apiKey' }, 'key2') + .addCookieAuth() + .addSecurityRequirements('bearer') + .addSecurityRequirements({ basic: [], cookie: [] }) + .addGlobalParameters({ + name: 'x-tenant-id', + in: 'header', + schema: { type: 'string' } + }) + .addGlobalParameters({ + name: 'x-api-version', + in: 'header', + schema: { type: 'string' } + }) + .build(); + + await SwaggerModule.loadPluginMetadata(async () => ({ + '@nestjs/swagger': { + models: [ + [ + import('./src/dogs/classes/dog.class'), + { + Dog: { + tags: { + description: 'Tags of the dog', + example: ['tag1', 'tag2'], + required: false + }, + siblings: { + required: false, + type: () => ({ + ids: { required: true, type: () => Number } + }) + } + } + } + ], + [ + import('./src/dogs/dto/create-dog.dto'), + { + CreateDogDto: { + name: { + description: 'Name of the dog' + } + } + } + ] + ], + controllers: [ + [ + import('./src/dogs/dogs.controller'), + { + DogsController: { + findAllBulk: { + type: [ + await import('./src/dogs/classes/dog.class').then( + (f) => f.Dog + ) + ], + summary: 'Find all dogs in bulk' + } + } + } + ] + ] + } + })); + }); + + it('should produce a valid OpenAPI 3.0 schema with versions split by modified path', async () => { + const api = SwaggerModule.createDocument(app, options); + console.log( + 'API name: %s, Version: %s', + api.info.title, + api.info.version + ); + expect(api.info.title).toEqual('Dogs example'); + + expect(api.paths['/api/dogs']['get']).toBeDefined(); + + expect(api.paths['/api/dogs version: v0']['post']['operationId']).toBe('DogsController_createNewV0'); + expect( + api.paths['/api/dogs version: v0']['post']['responses']['200']['content']['application/json']['schema']['type'] + ).toBe('array'); + + expect(api.paths['/api/dogs version: v1']['post']['operationId']).toBe('DogsController_createNewV1'); + expect( + api.paths['/api/dogs version: v1']['post']['responses']['200']['content']['application/json']['schema']['type'] + ).toBe('string'); + + expect(api.paths['/api/dogs version: v2']['post']['operationId']).toBe('DogsController_createNewV2'); + expect( + api.paths['/api/dogs version: v2']['post']['responses']['200']['content']['application/json']['schema']['type'] + ).toBe('array'); + }); + + it('should support filtering to subset of versions', async () => { + const api = SwaggerModule.createDocument(app, options, { includeVersions: ['v1', 'v2'] }); + console.log( + 'API name: %s, Version: %s', + api.info.title, + api.info.version + ); + expect(api.info.title).toEqual('Dogs example'); + + expect(api.paths['/api/dogs']['get']).toBeDefined(); + + expect(api.paths['/api/dogs version: v0']).toBeUndefined(); + + expect(api.paths['/api/dogs version: v1']['post']['operationId']).toBe('DogsController_createNewV1'); + expect( + api.paths['/api/dogs version: v1']['post']['responses']['200']['content']['application/json']['schema']['type'] + ).toBe('string'); + + expect(api.paths['/api/dogs version: v2']['post']['operationId']).toBe('DogsController_createNewV2'); + expect( + api.paths['/api/dogs version: v2']['post']['responses']['200']['content']['application/json']['schema']['type'] + ).toBe('array'); + }); + + it('should support filtering to a single version', async () => { + const api = SwaggerModule.createDocument(app, options, { includeVersions: ['v2'] }); + console.log( + 'API name: %s, Version: %s', + api.info.title, + api.info.version + ); + expect(api.info.title).toEqual('Dogs example'); + + expect(api.paths['/api/dogs']['get']).toBeDefined(); + expect(api.paths['/api/dogs version: v0']).toBeUndefined(); + expect(api.paths['/api/dogs version: v1']).toBeUndefined(); + expect(api.paths['/api/dogs version: v2']).toBeUndefined(); + expect(api.paths['/api/dogs']['post']['operationId']).toBe('DogsController_createNewV2'); + expect( + api.paths['/api/dogs']['post']['responses']['200']['content']['application/json']['schema']['type'] + ).toBe('array'); + }); +}); diff --git a/e2e/src/dog-app.module.ts b/e2e/src/dog-app.module.ts new file mode 100644 index 000000000..7ed091aaa --- /dev/null +++ b/e2e/src/dog-app.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { DogsModule } from './dogs/dogs.module'; + +@Module({ + imports: [DogsModule], + controllers: [AppController] +}) +export class ApplicationModule {} diff --git a/e2e/src/dogs/classes/dog.class.ts b/e2e/src/dogs/classes/dog.class.ts new file mode 100644 index 000000000..2c9134a8e --- /dev/null +++ b/e2e/src/dogs/classes/dog.class.ts @@ -0,0 +1,32 @@ +import { ApiExtension, ApiProperty } from '../../../../lib'; + +@ApiExtension('x-schema-extension', { test: 'test' }) +@ApiExtension('x-schema-extension-multiple', { test: 'test' }) +export class Dog { + @ApiProperty({ example: 'Chonk', description: 'The name of the Dog' }) + name: string; + + @ApiProperty({ example: 1, minimum: 0, description: 'The age of the Dog' }) + age: number; + + @ApiProperty({ + example: 'Pitt bull', + description: 'The breed of the Dog' + }) + breed: string; + + @ApiProperty({ + name: '_tags', + type: [String] + }) + tags?: string[]; + + @ApiProperty() + createdAt: Date; + + @ApiProperty({ + type: String, + isArray: true + }) + urls?: string[]; +} diff --git a/e2e/src/dogs/dogs.controller.ts b/e2e/src/dogs/dogs.controller.ts new file mode 100644 index 000000000..1636250de --- /dev/null +++ b/e2e/src/dogs/dogs.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Query, + Version, + VERSION_NEUTRAL +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiCallbacks, + ApiConsumes, + ApiDefaultGetter, + ApiExtension, + ApiHeader, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiSecurity, + ApiTags, + getSchemaPath +} from '../../../lib'; +import { DogsService } from './dogs.service'; +import { Dog } from './classes/dog.class'; +import { CreateDogDto } from './dto/create-dog.dto'; + +@ApiSecurity('basic') +@ApiBearerAuth() +@ApiSecurity({ key2: [], key1: [] }) +@ApiTags('dogs') +@ApiHeader({ + name: 'header', + required: false, + description: 'Test', + schema: { default: 'test' } +}) +@Controller('dogs') +export class DogsController { + constructor(private readonly dogsService: DogsService) {} + + @Get('') + @Version(VERSION_NEUTRAL) + @ApiResponse({ + status: 200, + description: 'The list of all dogs', + type: Array, + }) + getList(): Dog[] { + return this.dogsService.getAll(); + } + + @Post('') + @Version('0') + @ApiResponse({ + status: 200, + description: 'The tail array', + type: Array + }) + createNewV0(@Body() dogData: CreateDogDto): Dog { return this.dogsService.create(dogData); } + + @Post('') + @Version('1') + @ApiResponse({ + status: 200, + description: 'The tail string', + type: String + }) + createNewV1(@Body() dogData: CreateDogDto): Dog { return this.dogsService.create(dogData); } + + @Post('') + @Version('2') + @ApiResponse({ + status: 200, + description: 'The tail array.', + type: Array, + }) + createNewV2(@Body() dogData: CreateDogDto): [Dog, boolean] { return [this.dogsService.create(dogData), Math.random() > 0.5]; } + +} diff --git a/e2e/src/dogs/dogs.module.ts b/e2e/src/dogs/dogs.module.ts new file mode 100644 index 000000000..048e02f20 --- /dev/null +++ b/e2e/src/dogs/dogs.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DogsController } from './dogs.controller'; +import { DogsService } from './dogs.service'; + +@Module({ + controllers: [DogsController], + providers: [DogsService] +}) +export class DogsModule {} diff --git a/e2e/src/dogs/dogs.service.ts b/e2e/src/dogs/dogs.service.ts new file mode 100644 index 000000000..0dabad895 --- /dev/null +++ b/e2e/src/dogs/dogs.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { Dog } from './classes/dog.class'; +import { CreateDogDto } from './dto/create-dog.dto'; + +@Injectable() +export class DogsService { + private readonly dogs: Dog[] = []; + + getAll(): Dog[] { + return [...this.dogs]; + } + + create(dog: CreateDogDto): Dog { + this.dogs.push(dog); + return dog; + } +} diff --git a/e2e/src/dogs/dto/create-dog.dto.ts b/e2e/src/dogs/dto/create-dog.dto.ts new file mode 100644 index 000000000..83c91dde3 --- /dev/null +++ b/e2e/src/dogs/dto/create-dog.dto.ts @@ -0,0 +1,28 @@ +import { ApiExtension, ApiProperty } from '../../../../lib'; + +@ApiExtension('x-tags', ['foo', 'bar']) +export class CreateDogDto { + @ApiProperty() + readonly name: string; + + @ApiProperty({ minimum: 1, maximum: 200 }) + readonly age: number; + + @ApiProperty({ name: '_breed', type: String }) + readonly breed: string; + + @ApiProperty({ + format: 'uri', + type: [String] + }) + readonly tags?: string[]; + + @ApiProperty() + createdAt: Date; + + @ApiProperty({ + type: 'string', + isArray: true + }) + readonly urls?: string[]; +} diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts index 0e397c3ba..7a665cd47 100644 --- a/e2e/validate-schema.e2e-spec.ts +++ b/e2e/validate-schema.e2e-spec.ts @@ -214,4 +214,15 @@ describe('Validate OpenAPI schema', () => { } }); }); + + it('should support filtering to a single version', async () => { + const api = SwaggerModule.createDocument(app, options, { includeVersions: ['v2'] }); + console.log( + 'API name: %s, Version: %s', + api.info.title, + api.info.version + ); + expect(api.paths['/api/v1/alias1']).toBeUndefined(); + expect(api.paths['/api/v2/alias1']).toBeDefined(); + }); }); diff --git a/lib/decorators/api-callbacks.decorator.ts b/lib/decorators/api-callbacks.decorator.ts index a3c4bd9ff..d4f9084ee 100644 --- a/lib/decorators/api-callbacks.decorator.ts +++ b/lib/decorators/api-callbacks.decorator.ts @@ -1,6 +1,6 @@ import { DECORATORS } from '../constants'; import { createMixedDecorator } from './helpers'; -import { CallBackObject } from '../interfaces/callback-object.interface' +import { CallBackObject } from '../interfaces/callback-object.interface'; export function ApiCallbacks(...callbackObject: Array>) { return createMixedDecorator(DECORATORS.API_CALLBACKS, callbackObject); diff --git a/lib/explorers/api-callbacks.explorer.ts b/lib/explorers/api-callbacks.explorer.ts index 4c89b4edf..9e118e810 100644 --- a/lib/explorers/api-callbacks.explorer.ts +++ b/lib/explorers/api-callbacks.explorer.ts @@ -1,7 +1,7 @@ import { Type } from '@nestjs/common'; import { DECORATORS } from '../constants'; import { getSchemaPath } from '../utils'; -import { CallBackObject } from '../interfaces/callback-object.interface' +import { CallBackObject } from '../interfaces/callback-object.interface'; export const exploreApiCallbacksMetadata = ( instance: object, @@ -11,39 +11,42 @@ export const exploreApiCallbacksMetadata = ( const callbacksData = Reflect.getMetadata(DECORATORS.API_CALLBACKS, method); if (!callbacksData) return callbacksData; - return callbacksData.reduce((acc, callbackData: CallBackObject) => { - const { - name: eventName, - callbackUrl, - method: callbackMethod, - requestBody, - expectedResponse - } = callbackData; - return { - ...acc, - [eventName]: { - [callbackUrl]: { - [callbackMethod]: { - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - $ref: getSchemaPath(requestBody.type) + return callbacksData.reduce( + (acc, callbackData: CallBackObject) => { + const { + name: eventName, + callbackUrl, + method: callbackMethod, + requestBody, + expectedResponse + } = callbackData; + return { + ...acc, + [eventName]: { + [callbackUrl]: { + [callbackMethod]: { + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: getSchemaPath(requestBody.type) + } } } - } - }, - responses: { - [expectedResponse.status]: { - description: - expectedResponse.description || - 'Your server returns this code if it accepts the callback' + }, + responses: { + [expectedResponse.status]: { + description: + expectedResponse.description || + 'Your server returns this code if it accepts the callback' + } } } } } - } - }; - }, {}); + }; + }, + {} + ); }; diff --git a/lib/interfaces/callback-object.interface.ts b/lib/interfaces/callback-object.interface.ts index 3c6ca2b87..fe7553080 100644 --- a/lib/interfaces/callback-object.interface.ts +++ b/lib/interfaces/callback-object.interface.ts @@ -1,12 +1,12 @@ -export interface CallBackObject { - name: string, - callbackUrl: string, - method: string, +export interface CallBackObject { + name: string; + callbackUrl: string; + method: string; requestBody: { - type: T - }, + type: T; + }; expectedResponse: { - status: number - description?: string - }, + status: number; + description?: string; + }; } diff --git a/lib/interfaces/swagger-document-options.interface.ts b/lib/interfaces/swagger-document-options.interface.ts index bf9e7045b..98171d437 100644 --- a/lib/interfaces/swagger-document-options.interface.ts +++ b/lib/interfaces/swagger-document-options.interface.ts @@ -53,4 +53,11 @@ export interface SwaggerDocumentOptions { * @default true */ autoTagControllers?: boolean; + + /* + * Filter to routes of the matching versions as specified using the `@Version()` decorator with the version prefix applied. + * ex. To get routes decorated with `@Version('1')` use value of `['v1']` here. + * `VERSION_NEUTRAL` routes are always included. + */ + includeVersions?: string[]; } diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts index 7d1bd208b..2df4c6564 100644 --- a/lib/plugin/utils/plugin-utils.ts +++ b/lib/plugin/utils/plugin-utils.ts @@ -115,7 +115,10 @@ export function getTypeReferenceAsString( if (type.aliasSymbol) { return { typeName: 'Object', arrayDepth }; } - if (typeChecker.getApparentType(type).getSymbol().getEscapedName() === 'String') { + if ( + typeChecker.getApparentType(type).getSymbol().getEscapedName() === + 'String' + ) { return { typeName: String.name, arrayDepth }; } return { typeName: undefined }; @@ -250,7 +253,8 @@ export function isAutoGeneratedEnumUnion( return undefined; } const undefinedTypeIndex = type.types.findIndex( - (type: any) => type.intrinsicName === 'undefined' || type.intrinsicName === 'null' + (type: any) => + type.intrinsicName === 'undefined' || type.intrinsicName === 'null' ); if (undefinedTypeIndex < 0) { return undefined; diff --git a/lib/swagger-explorer.ts b/lib/swagger-explorer.ts index 770776734..b179ba945 100644 --- a/lib/swagger-explorer.ts +++ b/lib/swagger-explorer.ts @@ -339,7 +339,7 @@ export class SwaggerExplorer { globalPrefix, controllerVersion, ctrlPath: this.reflectControllerPath(metatype), - versioningOptions: applicationConfig.getVersioning() + versioningOptions }, requestMethod ); @@ -364,6 +364,8 @@ export class SwaggerExplorer { instance, method.name )}_${requestMethod.toLowerCase()}`, + versions, + versionType: versioningOptions?.type, ...apiExtension })); } @@ -378,6 +380,8 @@ export class SwaggerExplorer { method: RequestMethod[requestMethod].toLowerCase(), path: fullPath === '' ? '/' : fullPath, operationId: this.getOperationId(instance, methodKey, pathVersion), + versions, + versionType: versioningOptions?.type, ...apiExtension }; }) @@ -402,7 +406,7 @@ export class SwaggerExplorer { ) { let versions: string[] = []; - if (!versionValue || versioningOptions?.type !== VersioningType.URI) { + if (!versionValue) { return versions; } @@ -412,6 +416,10 @@ export class SwaggerExplorer { versions = [versionValue]; } + if (!versioningOptions) { + return versions; + } + const prefix = this.routePathFactory.getVersionPrefix(versioningOptions); versions = versions.map((v) => `${prefix}${v}`); @@ -548,7 +556,7 @@ export class SwaggerExplorer { metatype: Type | Function, versioningOptions: VersioningOptions | undefined ): VersionValue | undefined { - if (versioningOptions?.type === VersioningType.URI) { + if (versioningOptions) { return ( Reflect.getMetadata(VERSION_METADATA, metatype) ?? versioningOptions.defaultVersion diff --git a/lib/swagger-scanner.ts b/lib/swagger-scanner.ts index b7ee197d2..ba0cc3462 100644 --- a/lib/swagger-scanner.ts +++ b/lib/swagger-scanner.ts @@ -41,7 +41,8 @@ export class SwaggerScanner { ignoreGlobalPrefix = false, operationIdFactory, linkNameFactory, - autoTagControllers = true + autoTagControllers = true, + includeVersions } = options; const container = (app as any).container as NestContainer; @@ -104,7 +105,10 @@ export class SwaggerScanner { this.addExtraModels(schemas, extraModels); return { - ...this.transformer.normalizePaths(flatten(denormalizedPaths)), + ...this.transformer.normalizePaths( + flatten(denormalizedPaths), + includeVersions + ), components: { schemas: schemas as Record } diff --git a/lib/swagger-transformer.ts b/lib/swagger-transformer.ts index 72f502080..934e9fa30 100644 --- a/lib/swagger-transformer.ts +++ b/lib/swagger-transformer.ts @@ -1,17 +1,61 @@ -import { INestApplication } from '@nestjs/common'; +import { INestApplication, VersioningType } from '@nestjs/common'; import { filter, groupBy, keyBy, mapValues, omit } from 'lodash'; import { OpenAPIObject } from './interfaces'; import { ModuleRoute } from './interfaces/module-route.interface'; import { sortObjectLexicographically } from './utils/sort-object-lexicographically'; +type DenormalizedDoc = Partial & Record<'root', any>; + export class SwaggerTransformer { + private hasRootKey(r: DenormalizedDoc): boolean { + return !!r.root; + } + + private hasRootKeyWithVersions(includeVersions: string[]) { + return ({ root }: DenormalizedDoc) => + root && + // Versions is null if route lacks any version and no default version is set + // Versions array is empty when route is VERSION_NEUTRAL + // In both cases we want to include the route + (!root.versions?.length || + // Else only include if route has matching version + (root.versionType === VersioningType.HEADER && + root.versions.some((v) => includeVersions.includes(v))) || + (root.versionType === VersioningType.URI && + includeVersions.some((v) => root.path.includes(`/${v}/`)))); + } + + private getVersionedPath(includeVersions?: string[]) { + return ({ root }: DenormalizedDoc) => { + if ( + // URI-based versioning already results in unique paths + root.versionType !== VersioningType.URI && + // Versions array is empty when route is VERSION_NEUTRAL + root.versions?.length && + // If we're not filtering down to a single version make the versions part of the path + (!includeVersions || includeVersions?.length > 1) + ) { + return `${root.path} version${ + root.versions.length > 1 ? 's' : '' + }: ${root.versions.join(', ')}`; + } + return root.path; + }; + } + public normalizePaths( - denormalizedDoc: (Partial & Record<'root', any>)[] + denormalizedDoc: DenormalizedDoc[], + includeVersions?: string[] ): Record<'paths', OpenAPIObject['paths']> { - const roots = filter(denormalizedDoc, (r) => r.root); + const roots = filter( + denormalizedDoc, + includeVersions + ? this.hasRootKeyWithVersions(includeVersions) + : this.hasRootKey + ); const groupedByPath = groupBy( roots, - ({ root }: Record<'root', any>) => root.path + this.getVersionedPath(includeVersions) ); const paths = mapValues(groupedByPath, (routes) => { const keyByMethod = keyBy( @@ -21,7 +65,7 @@ export class SwaggerTransformer { return mapValues(keyByMethod, (route: any) => { const mergedDefinition = { ...omit(route, 'root'), - ...omit(route.root, ['method', 'path']) + ...omit(route.root, ['method', 'path', 'versions', 'versionType']) }; return sortObjectLexicographically(mergedDefinition); }); diff --git a/lib/utils/resolve-path.util.ts b/lib/utils/resolve-path.util.ts index c8815a9d5..d826f8bcd 100644 --- a/lib/utils/resolve-path.util.ts +++ b/lib/utils/resolve-path.util.ts @@ -2,4 +2,4 @@ import * as pathLib from 'path'; export function resolvePath(path: string): string { return path ? pathLib.resolve(path) : path; -} \ No newline at end of file +}