diff --git a/.circleci/config.yml b/.circleci/config.yml index d677be46b..24c1aa928 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,27 +3,23 @@ version: 2 aliases: - &restore-cache restore_cache: - key: dependency-cache-{{ checksum "package.json" }} + key: dependency-cache-{{ checksum "package.json" }} - &install-deps run: - name: Install dependencies - command: npm ci + name: Install dependencies + command: npm ci - &build-packages run: - name: Build - command: npm run build + name: Build + command: npm run build - &run-unit-tests run: - name: Test - command: npm run test -- --runInBand - - &run-unit-tests - run: - name: Test (TypeScript < v4.8) - command: npm i --no-save -D typescript@4.7.2 && npm run test -- --runInBand + name: Test + command: npm run test -- --runInBand - &run-e2e-tests run: - name: E2E test - command: npm run test:e2e + name: E2E test + command: npm run test:e2e jobs: build: @@ -46,7 +42,7 @@ jobs: - ./node_modules - run: name: Build - command: npm run build + command: npm run build unit_tests: working_directory: ~/nest @@ -81,4 +77,3 @@ workflows: - e2e_tests: requires: - build - diff --git a/e2e/api-spec.json b/e2e/api-spec.json index 2423a5bae..5cf27e9b8 100644 --- a/e2e/api-spec.json +++ b/e2e/api-spec.json @@ -586,6 +586,7 @@ "/api/cats/bulk": { "get": { "operationId": "CatsController_findAllBulk", + "summary": "Find all cats in bulk", "parameters": [ { "name": "header", @@ -607,7 +608,17 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Cat" + } + } + } + } } }, "tags": [ @@ -992,6 +1003,7 @@ "type": "object", "properties": { "name": { + "description": "Name of the cat", "type": "string" }, "age": { @@ -1080,6 +1092,11 @@ "description": "The breed of the Cat" }, "_tags": { + "description": "Tags of the cat", + "example": [ + "tag1", + "tag2" + ], "type": "array", "items": { "type": "string" diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts index 5482cf690..ca514e0c2 100644 --- a/e2e/validate-schema.e2e-spec.ts +++ b/e2e/validate-schema.e2e-spec.ts @@ -1,6 +1,7 @@ import { INestApplication } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { writeFileSync } from 'fs'; +import { OpenAPIV3 } from 'openapi-types'; import { join } from 'path'; import * as SwaggerParser from 'swagger-parser'; import { @@ -12,7 +13,6 @@ import { import { ApplicationModule } from './src/app.module'; import { Cat } from './src/cats/classes/cat.class'; import { TagDto } from './src/cats/dto/tag.dto'; -import { OpenAPIV3 } from 'openapi-types'; describe('Validate OpenAPI schema', () => { let app: INestApplication; @@ -49,6 +49,48 @@ describe('Validate OpenAPI schema', () => { }); it('should produce a valid OpenAPI 3.0 schema', async () => { + SwaggerModule.loadPluginMetadata({ + metadata: { + '@nestjs/swagger': { + models: [ + [ + require('./src/cats/classes/cat.class'), + { + Cat: { + tags: { + description: 'Tags of the cat', + example: ['tag1', 'tag2'] + } + } + } + ], + [ + require('./src/cats/dto/create-cat.dto'), + { + CreateCatDto: { + name: { + description: 'Name of the cat' + } + } + } + ] + ], + controllers: [ + [ + require('./src/cats/cats.controller'), + { + CatsController: { + findAllBulk: { + type: [require('./src/cats/classes/cat.class').Cat], + summary: 'Find all cats in bulk' + } + } + } + ] + ] + } + } + }); const document = SwaggerModule.createDocument(app, options); const doc = JSON.stringify(document, null, 2); diff --git a/lib/decorators/api-operation.decorator.ts b/lib/decorators/api-operation.decorator.ts index d95cb8f8e..9add4351c 100644 --- a/lib/decorators/api-operation.decorator.ts +++ b/lib/decorators/api-operation.decorator.ts @@ -9,7 +9,10 @@ const defaultOperationOptions: ApiOperationOptions = { summary: '' }; -export function ApiOperation(options: ApiOperationOptions): MethodDecorator { +export function ApiOperation( + options: ApiOperationOptions, + { overrideExisting } = { overrideExisting: true } +): MethodDecorator { return createMethodDecorator( DECORATORS.API_OPERATION, pickBy( @@ -18,6 +21,7 @@ export function ApiOperation(options: ApiOperationOptions): MethodDecorator { ...options } as ApiOperationOptions, negate(isUndefined) - ) + ), + { overrideExisting } ); } diff --git a/lib/decorators/api-response.decorator.ts b/lib/decorators/api-response.decorator.ts index 98339fdaf..e89c72915 100644 --- a/lib/decorators/api-response.decorator.ts +++ b/lib/decorators/api-response.decorator.ts @@ -2,9 +2,9 @@ import { HttpStatus, Type } from '@nestjs/common'; import { omit } from 'lodash'; import { DECORATORS } from '../constants'; import { + ReferenceObject, ResponseObject, - SchemaObject, - ReferenceObject + SchemaObject } from '../interfaces/open-api-spec.interface'; import { getTypeIsArrayTuple } from './helpers'; @@ -26,7 +26,8 @@ export interface ApiResponseSchemaHost export type ApiResponseOptions = ApiResponseMetadata | ApiResponseSchemaHost; export function ApiResponse( - options: ApiResponseOptions + options: ApiResponseOptions, + { overrideExisting } = { overrideExisting: true } ): MethodDecorator & ClassDecorator { const [type, isArray] = getTypeIsArrayTuple( (options as ApiResponseMetadata).type, @@ -46,8 +47,14 @@ export function ApiResponse( descriptor?: TypedPropertyDescriptor ): any => { if (descriptor) { - const responses = - Reflect.getMetadata(DECORATORS.API_RESPONSE, descriptor.value) || {}; + const responses = Reflect.getMetadata( + DECORATORS.API_RESPONSE, + descriptor.value + ); + + if (responses && !overrideExisting) { + return descriptor; + } Reflect.defineMetadata( DECORATORS.API_RESPONSE, { @@ -58,8 +65,10 @@ export function ApiResponse( ); return descriptor; } - const responses = - Reflect.getMetadata(DECORATORS.API_RESPONSE, target) || {}; + const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, target); + if (responses && !overrideExisting) { + return descriptor; + } Reflect.defineMetadata( DECORATORS.API_RESPONSE, { diff --git a/lib/decorators/helpers.ts b/lib/decorators/helpers.ts index e700a6bee..d81f8b698 100644 --- a/lib/decorators/helpers.ts +++ b/lib/decorators/helpers.ts @@ -4,13 +4,26 @@ import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; export function createMethodDecorator( metakey: string, - metadata: T + metadata: T, + { overrideExisting } = { overrideExisting: true } ): MethodDecorator { return ( target: object, key: string | symbol, descriptor: PropertyDescriptor ) => { + if (typeof metadata === 'object') { + const prevValue = Reflect.getMetadata(metakey, descriptor.value); + if (prevValue && !overrideExisting) { + return descriptor; + } + Reflect.defineMetadata( + metakey, + { ...prevValue, ...metadata }, + descriptor.value + ); + return descriptor; + } Reflect.defineMetadata(metakey, metadata, descriptor.value); return descriptor; }; diff --git a/lib/explorers/api-operation.explorer.ts b/lib/explorers/api-operation.explorer.ts index 37e8a82c4..f3e77032a 100644 --- a/lib/explorers/api-operation.explorer.ts +++ b/lib/explorers/api-operation.explorer.ts @@ -1,8 +1,54 @@ import { Type } from '@nestjs/common'; import { DECORATORS } from '../constants'; +import { ApiOperation } from '../decorators/api-operation.decorator'; +import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; export const exploreApiOperationMetadata = ( instance: object, prototype: Type, method: object -) => Reflect.getMetadata(DECORATORS.API_OPERATION, method); +) => { + applyMetadataFactory(prototype); + return Reflect.getMetadata(DECORATORS.API_OPERATION, method); +}; + +function applyMetadataFactory(prototype: Type) { + const classPrototype = prototype; + do { + if (!prototype.constructor) { + return; + } + if (!prototype.constructor[METADATA_FACTORY_NAME]) { + continue; + } + const metadata = prototype.constructor[METADATA_FACTORY_NAME](); + const methodKeys = Object.keys(metadata); + methodKeys.forEach((key) => { + const operationMeta = {}; + const { summary, deprecated, tags } = metadata[key]; + + applyIfNotNil(operationMeta, 'summary', summary); + applyIfNotNil(operationMeta, 'deprecated', deprecated); + applyIfNotNil(operationMeta, 'tags', tags); + + if (Object.keys(operationMeta).length === 0) { + return; + } + ApiOperation(operationMeta, { overrideExisting: false })( + classPrototype, + key, + Object.getOwnPropertyDescriptor(classPrototype, key) + ); + }); + } while ( + (prototype = Reflect.getPrototypeOf(prototype) as Type) && + prototype !== Object.prototype && + prototype + ); +} + +function applyIfNotNil(target: Record, key: string, value: any) { + if (value !== undefined && value !== null) { + target[key] = value; + } +} diff --git a/lib/explorers/api-response.explorer.ts b/lib/explorers/api-response.explorer.ts index 2eda59425..1079cf5e0 100644 --- a/lib/explorers/api-response.explorer.ts +++ b/lib/explorers/api-response.explorer.ts @@ -3,8 +3,9 @@ import { HTTP_CODE_METADATA, METHOD_METADATA } from '@nestjs/common/constants'; import { isEmpty } from '@nestjs/common/utils/shared.utils'; import { get, mapValues, omit } from 'lodash'; import { DECORATORS } from '../constants'; -import { ApiResponseMetadata } from '../decorators'; +import { ApiResponse, ApiResponseMetadata } from '../decorators'; import { SchemaObject } from '../interfaces/open-api-spec.interface'; +import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; import { ResponseObjectFactory } from '../services/response-object-factory'; import { mergeAndUniq } from '../utils/merge-and-uniq.util'; @@ -32,6 +33,8 @@ export const exploreApiResponseMetadata = ( prototype: Type, method: Function ) => { + applyMetadataFactory(prototype); + const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, method); if (responses) { const classProduces = Reflect.getMetadata( @@ -84,3 +87,36 @@ const mapResponsesToSwaggerResponses = ( ); return mapValues(openApiResponses, omitParamType); }; + +function applyMetadataFactory(prototype: Type) { + const classPrototype = prototype; + do { + if (!prototype.constructor) { + return; + } + if (!prototype.constructor[METADATA_FACTORY_NAME]) { + continue; + } + const metadata = prototype.constructor[METADATA_FACTORY_NAME](); + const methodKeys = Object.keys(metadata); + methodKeys.forEach((key) => { + const { summary, deprecated, tags, ...meta } = metadata[key]; + + if (Object.keys(meta).length === 0) { + return; + } + if (meta.status === undefined) { + meta.status = getStatusCode(classPrototype[key]); + } + ApiResponse(meta, { overrideExisting: false })( + classPrototype, + key, + Object.getOwnPropertyDescriptor(classPrototype, key) + ); + }); + } while ( + (prototype = Reflect.getPrototypeOf(prototype) as Type) && + prototype !== Object.prototype && + prototype + ); +} diff --git a/lib/extra/swagger-shim.ts b/lib/extra/swagger-shim.ts index b0f8b367b..93cb57571 100644 --- a/lib/extra/swagger-shim.ts +++ b/lib/extra/swagger-shim.ts @@ -167,3 +167,9 @@ export function PickType() { export function getSchemaPath() { return () => ''; } +export function before() { + return () => ''; +} +export function ReadonlyVisitor() { + return class {}; +} diff --git a/lib/interfaces/swagger-custom-options.interface.ts b/lib/interfaces/swagger-custom-options.interface.ts index f9fc32285..5853e5fa1 100644 --- a/lib/interfaces/swagger-custom-options.interface.ts +++ b/lib/interfaces/swagger-custom-options.interface.ts @@ -1,4 +1,6 @@ import { SwaggerUiOptions } from './swagger-ui-options.interface'; +import { SwaggerDocumentOptions } from './swagger-document-options.interface'; +import { OpenAPIObject } from './open-api-spec.interface'; export interface SwaggerCustomOptions { useGlobalPrefix?: boolean; diff --git a/lib/plugin/compiler-plugin.ts b/lib/plugin/compiler-plugin.ts index 84ae76c4c..f62048b56 100644 --- a/lib/plugin/compiler-plugin.ts +++ b/lib/plugin/compiler-plugin.ts @@ -1,12 +1,11 @@ import * as ts from 'typescript'; import { mergePluginOptions } from './merge-options'; +import { isFilenameMatched } from './utils/is-filename-matched.util'; import { ControllerClassVisitor } from './visitors/controller-class.visitor'; import { ModelClassVisitor } from './visitors/model-class.visitor'; const modelClassVisitor = new ModelClassVisitor(); const controllerClassVisitor = new ControllerClassVisitor(); -const isFilenameMatched = (patterns: string[], filename: string) => - patterns.some((path) => filename.includes(path)); export const before = (options?: Record, program?: ts.Program) => { options = mergePluginOptions(options); diff --git a/lib/plugin/index.ts b/lib/plugin/index.ts index 2a513e232..f980bda5b 100644 --- a/lib/plugin/index.ts +++ b/lib/plugin/index.ts @@ -1 +1,2 @@ export * from './compiler-plugin'; +export * from './visitors/readonly.visitor'; diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts index 51aded4d1..06e60b68b 100644 --- a/lib/plugin/merge-options.ts +++ b/lib/plugin/merge-options.ts @@ -7,6 +7,8 @@ export interface PluginOptions { dtoKeyOfComment?: string; controllerKeyOfComment?: string; introspectComments?: boolean; + readonly?: boolean; + pathToSource?: string; } const defaultOptions: PluginOptions = { @@ -15,7 +17,8 @@ const defaultOptions: PluginOptions = { classValidatorShim: true, dtoKeyOfComment: 'description', controllerKeyOfComment: 'description', - introspectComments: false + introspectComments: false, + readonly: false }; export const mergePluginOptions = ( diff --git a/lib/plugin/metadata-loader.ts b/lib/plugin/metadata-loader.ts new file mode 100644 index 000000000..807689784 --- /dev/null +++ b/lib/plugin/metadata-loader.ts @@ -0,0 +1,28 @@ +import { METADATA_FACTORY_NAME } from './plugin-constants'; + +export class MetadataLoader { + async load(metadata: Record) { + const pkgMetadata = metadata['@nestjs/swagger']; + if (!pkgMetadata) { + return; + } + const { models, controllers } = pkgMetadata; + if (models) { + await this.applyMetadata(models); + } + if (controllers) { + await this.applyMetadata(controllers); + } + } + + private async applyMetadata(meta: Record) { + const loadPromises = meta.map(async ([fileImport, fileMeta]) => { + const fileRef = await fileImport; + Object.keys(fileMeta).map((key) => { + const clsRef = fileRef[key]; + clsRef[METADATA_FACTORY_NAME] = () => fileMeta[key]; + }); + }); + await Promise.all(loadPromises); + } +} diff --git a/lib/plugin/utils/is-filename-matched.util.ts b/lib/plugin/utils/is-filename-matched.util.ts new file mode 100644 index 000000000..ef5139c79 --- /dev/null +++ b/lib/plugin/utils/is-filename-matched.util.ts @@ -0,0 +1,2 @@ +export const isFilenameMatched = (patterns: string[], filename: string) => + patterns.some((path) => filename.includes(path)); diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts index 793855d4c..154cc45eb 100644 --- a/lib/plugin/utils/plugin-utils.ts +++ b/lib/plugin/utils/plugin-utils.ts @@ -1,6 +1,7 @@ import { head } from 'lodash'; import { isAbsolute, posix } from 'path'; import * as ts from 'typescript'; +import { PluginOptions } from '../merge-options'; import { getDecoratorName, getText, @@ -17,7 +18,7 @@ import { export function getDecoratorOrUndefinedByNames( names: string[], - decorators: ts.NodeArray, + decorators: readonly ts.Decorator[], factory: ts.NodeFactory ): ts.Decorator | undefined { return (decorators || factory.createNodeArray()).find((item) => { @@ -117,7 +118,11 @@ export function hasPropertyKey( .some((item) => item.name.getText() === key); } -export function replaceImportPath(typeReference: string, fileName: string) { +export function replaceImportPath( + typeReference: string, + fileName: string, + options: PluginOptions +) { if (!typeReference.includes('import')) { return typeReference; } @@ -135,7 +140,10 @@ export function replaceImportPath(typeReference: string, fileName: string) { require.resolve(importPath); return typeReference.replace('import', 'require'); } catch (_error) { - let relativePath = posix.relative(posix.dirname(fileName), importPath); + const from = options?.readonly + ? options.pathToSource + : posix.dirname(fileName); + let relativePath = posix.relative(from, importPath); relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; const nodeModulesText = 'node_modules'; diff --git a/lib/plugin/visitors/abstract.visitor.ts b/lib/plugin/visitors/abstract.visitor.ts index 3f35ecd1f..c8ca2721c 100644 --- a/lib/plugin/visitors/abstract.visitor.ts +++ b/lib/plugin/visitors/abstract.visitor.ts @@ -9,71 +9,36 @@ export class AbstractFileVisitor { factory: ts.NodeFactory | undefined, program: ts.Program ): ts.SourceFile { - if (major <= 4 && minor < 2) { - throw new Error('Nest CLI plugin does not support TypeScript < v4.2'); + if (major <= 4 && minor < 8) { + throw new Error('Nest CLI plugin does not support TypeScript < v4.8'); } - const importEqualsDeclaration: ts.ImportDeclaration = - major >= 4 && minor >= 2 - ? minor >= 8 - ? (factory.createImportEqualsDeclaration as any)( - undefined, - false, - factory.createIdentifier(OPENAPI_NAMESPACE), - factory.createExternalModuleReference( - factory.createStringLiteral(OPENAPI_PACKAGE_NAME) - ) - ) - : (factory.createImportEqualsDeclaration as any)( - undefined, - undefined, - false, - OPENAPI_NAMESPACE, - factory.createExternalModuleReference( - factory.createStringLiteral(OPENAPI_PACKAGE_NAME) - ) - ) - : (factory.createImportEqualsDeclaration as any)( - undefined, - undefined, - OPENAPI_NAMESPACE, - factory.createExternalModuleReference( - factory.createStringLiteral(OPENAPI_PACKAGE_NAME) - ) - ); + const importEqualsDeclaration: ts.ImportEqualsDeclaration = + factory.createImportEqualsDeclaration( + undefined, + false, + factory.createIdentifier(OPENAPI_NAMESPACE), + factory.createExternalModuleReference( + factory.createStringLiteral(OPENAPI_PACKAGE_NAME) + ) + ); const compilerOptions = program.getCompilerOptions(); - // Support TS v4.8+ if ( compilerOptions.module >= ts.ModuleKind.ES2015 && compilerOptions.module <= ts.ModuleKind.ESNext ) { - const importAsDeclaration = - (minor >= 8 && major >= 4) || major >= 5 - ? (factory.createImportDeclaration as any)( - undefined, - factory.createImportClause( - false, - undefined, - factory.createNamespaceImport( - factory.createIdentifier(OPENAPI_NAMESPACE) - ) - ), - factory.createStringLiteral(OPENAPI_PACKAGE_NAME), - undefined - ) - : (factory.createImportDeclaration as any)( - undefined, - undefined, - factory.createImportClause( - false, - undefined, - factory.createNamespaceImport( - factory.createIdentifier(OPENAPI_NAMESPACE) - ) - ), - factory.createStringLiteral(OPENAPI_PACKAGE_NAME), - undefined - ); + const importAsDeclaration = (factory.createImportDeclaration as any)( + undefined, + factory.createImportClause( + false, + undefined, + factory.createNamespaceImport( + factory.createIdentifier(OPENAPI_NAMESPACE) + ) + ), + factory.createStringLiteral(OPENAPI_PACKAGE_NAME), + undefined + ); return factory.updateSourceFile(sourceFile, [ importAsDeclaration, ...sourceFile.statements diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts index 48b146543..76745f316 100644 --- a/lib/plugin/visitors/controller-class.visitor.ts +++ b/lib/plugin/visitors/controller-class.visitor.ts @@ -1,4 +1,5 @@ import { compact, head } from 'lodash'; +import { posix } from 'path'; import * as ts from 'typescript'; import { ApiOperation, ApiResponse } from '../../decorators'; import { PluginOptions } from '../merge-options'; @@ -18,12 +19,31 @@ import { } from '../utils/plugin-utils'; import { AbstractFileVisitor } from './abstract.visitor'; -const [tsVersionMajor, tsVersionMinor] = ts.versionMajorMinor - ?.split('.') - .map((x) => +x); -const isInUpdatedAstContext = tsVersionMinor >= 8 || tsVersionMajor > 4; +type ClassMetadata = Record; export class ControllerClassVisitor extends AbstractFileVisitor { + private readonly _collectedMetadata: Record< + string, + Record + > = {}; + + get collectedMetadata(): Array< + [ts.CallExpression, Record] + > { + const metadataWithImports = []; + Object.keys(this._collectedMetadata).forEach((filePath) => { + const metadata = this._collectedMetadata[filePath]; + const path = filePath.replace(/\.[jt]s$/, ''); + const importExpr = ts.factory.createCallExpression( + ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression, + undefined, + [ts.factory.createStringLiteral(path)] + ); + metadataWithImports.push([importExpr, metadata]); + }); + return metadataWithImports; + } + visit( sourceFile: ts.SourceFile, ctx: ts.TransformationContext, @@ -31,24 +51,59 @@ export class ControllerClassVisitor extends AbstractFileVisitor { options: PluginOptions ) { const typeChecker = program.getTypeChecker(); - sourceFile = this.updateImports(sourceFile, ctx.factory, program); + if (!options.readonly) { + sourceFile = this.updateImports(sourceFile, ctx.factory, program); + } const visitNode = (node: ts.Node): ts.Node => { if (ts.isMethodDeclaration(node)) { try { - return this.addDecoratorToNode( + const metadata: ClassMetadata = {}; + const updatedNode = this.addDecoratorToNode( ctx.factory, node, typeChecker, options, - sourceFile.fileName, - sourceFile + sourceFile, + metadata ); + if (!options.readonly) { + return updatedNode; + } else { + const filePath = this.normalizeImportPath( + options.pathToSource, + sourceFile.fileName + ); + + if (!this._collectedMetadata[filePath]) { + this._collectedMetadata[filePath] = {}; + } + + const parent = node.parent as ts.ClassDeclaration; + const clsName = parent.name?.getText(); + + if (clsName) { + if (!this._collectedMetadata[filePath][clsName]) { + this._collectedMetadata[filePath][clsName] = {}; + } + Object.assign( + this._collectedMetadata[filePath][clsName], + metadata + ); + } + } } catch { - return node; + if (!options.readonly) { + return node; + } } } - return ts.visitEachChild(node, visitNode, ctx); + + if (options.readonly) { + ts.forEachChild(node, visitNode); + } else { + return ts.visitEachChild(node, visitNode, ctx); + } }; return ts.visitNode(sourceFile, visitNode); } @@ -58,13 +113,13 @@ export class ControllerClassVisitor extends AbstractFileVisitor { compilerNode: ts.MethodDeclaration, typeChecker: ts.TypeChecker, options: PluginOptions, - hostFilename: string, - sourceFile: ts.SourceFile + sourceFile: ts.SourceFile, + metadata: ClassMetadata ): ts.MethodDeclaration { - // Support both >= v4.8 and v4.7 and lower - const decorators = (ts as any).canHaveDecorators - ? (ts as any).getDecorators(compilerNode) - : (compilerNode as any).decorators; + const hostFilename = sourceFile.fileName; + const decorators = + ts.canHaveDecorators(compilerNode) && ts.getDecorators(compilerNode); + if (!decorators) { return compilerNode; } @@ -75,7 +130,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor { decorators, options, sourceFile, - typeChecker + typeChecker, + metadata ); const removeExistingApiOperationDecorator = apiOperationDecoratorsArray.length > 0; @@ -86,12 +142,16 @@ export class ControllerClassVisitor extends AbstractFileVisitor { ) : decorators; - // Support both >= v4.8 and v4.7 and lower - const modifiers = - (isInUpdatedAstContext - ? (ts as any).getModifiers(compilerNode) - : compilerNode.modifiers) ?? []; - + const modifiers = ts.getModifiers(compilerNode) ?? []; + const objectLiteralExpr = this.createDecoratorObjectLiteralExpr( + factory, + compilerNode, + typeChecker, + factory.createNodeArray(), + hostFilename, + metadata, + options + ); const updatedDecorators = [ ...apiOperationDecoratorsArray, ...existingDecorators, @@ -99,52 +159,36 @@ export class ControllerClassVisitor extends AbstractFileVisitor { factory.createCallExpression( factory.createIdentifier(`${OPENAPI_NAMESPACE}.${ApiResponse.name}`), undefined, - [ - this.createDecoratorObjectLiteralExpr( - factory, - compilerNode, - typeChecker, - factory.createNodeArray(), - hostFilename - ) - ] + [factory.createObjectLiteralExpression(objectLiteralExpr.properties)] ) ) ]; - return isInUpdatedAstContext - ? (factory as any).updateMethodDeclaration( - compilerNode, - [...updatedDecorators, ...modifiers], - compilerNode.asteriskToken, - compilerNode.name, - compilerNode.questionToken, - compilerNode.typeParameters, - compilerNode.parameters, - compilerNode.type, - compilerNode.body - ) - : (factory as any).updateMethodDeclaration( - compilerNode, - updatedDecorators, - modifiers, - compilerNode.asteriskToken, - compilerNode.name, - compilerNode.questionToken, - compilerNode.typeParameters, - compilerNode.parameters, - compilerNode.type, - compilerNode.body - ); + if (!options.readonly) { + return factory.updateMethodDeclaration( + compilerNode, + [...updatedDecorators, ...modifiers], + compilerNode.asteriskToken, + compilerNode.name, + compilerNode.questionToken, + compilerNode.typeParameters, + compilerNode.parameters, + compilerNode.type, + compilerNode.body + ); + } else { + return compilerNode; + } } createApiOperationDecorator( factory: ts.NodeFactory, node: ts.MethodDeclaration, - nodeArray: ts.NodeArray, + decorators: readonly ts.Decorator[], options: PluginOptions, sourceFile: ts.SourceFile, - typeChecker: ts.TypeChecker + typeChecker: ts.TypeChecker, + metadata: ClassMetadata ) { if (!options.introspectComments) { return []; @@ -152,7 +196,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor { const keyToGenerate = options.controllerKeyOfComment; const apiOperationDecorator = getDecoratorOrUndefinedByNames( [ApiOperation.name], - nodeArray, + decorators, factory ); const apiOperationExpr: ts.ObjectLiteralExpression | undefined = @@ -188,10 +232,27 @@ export class ControllerClassVisitor extends AbstractFileVisitor { properties.push(deprecatedPropertyAssignment); } + const objectLiteralExpr = factory.createObjectLiteralExpression( + compact(properties) + ); const apiOperationDecoratorArguments: ts.NodeArray = - factory.createNodeArray([ - factory.createObjectLiteralExpression(compact(properties)) + factory.createNodeArray([objectLiteralExpr]); + + const methodKey = node.name.getText(); + if (metadata[methodKey]) { + const existingObjectLiteralExpr = metadata[methodKey]; + const existingProperties = existingObjectLiteralExpr.properties; + const updatedProperties = factory.createNodeArray([ + ...existingProperties, + ...compact(properties) ]); + const updatedObjectLiteralExpr = + factory.createObjectLiteralExpression(updatedProperties); + metadata[methodKey] = updatedObjectLiteralExpr; + } else { + metadata[methodKey] = objectLiteralExpr; + } + if (apiOperationDecorator) { const expr = apiOperationDecorator.expression as any as ts.CallExpression; const updatedCallExpr = factory.updateCallExpression( @@ -221,20 +282,46 @@ export class ControllerClassVisitor extends AbstractFileVisitor { node: ts.MethodDeclaration, typeChecker: ts.TypeChecker, existingProperties: ts.NodeArray = factory.createNodeArray(), - hostFilename: string + hostFilename: string, + metadata: ClassMetadata, + options: PluginOptions ): ts.ObjectLiteralExpression { - const properties = [ - ...existingProperties, - this.createStatusPropertyAssignment(factory, node, existingProperties), + let properties = []; + if (!options.readonly) { + properties = properties.concat( + existingProperties, + this.createStatusPropertyAssignment(factory, node, existingProperties) + ); + } + properties = properties.concat([ this.createTypePropertyAssignment( factory, node, typeChecker, existingProperties, - hostFilename + hostFilename, + options ) - ]; - return factory.createObjectLiteralExpression(compact(properties)); + ]); + const objectLiteralExpr = factory.createObjectLiteralExpression( + compact(properties) + ); + + const methodKey = node.name.getText(); + const existingExprOrUndefined = metadata[methodKey]; + if (existingExprOrUndefined) { + const existingProperties = existingExprOrUndefined.properties; + const updatedProperties = factory.createNodeArray([ + ...existingProperties, + ...compact(properties) + ]); + const updatedObjectLiteralExpr = + factory.createObjectLiteralExpression(updatedProperties); + metadata[methodKey] = updatedObjectLiteralExpr; + } else { + metadata[methodKey] = objectLiteralExpr; + } + return objectLiteralExpr; } createTypePropertyAssignment( @@ -242,7 +329,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor { node: ts.MethodDeclaration, typeChecker: ts.TypeChecker, existingProperties: ts.NodeArray, - hostFilename: string + hostFilename: string, + options: PluginOptions ) { if (hasPropertyKey('type', existingProperties)) { return undefined; @@ -259,7 +347,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor { if (typeReference.includes('node_modules')) { return undefined; } - typeReference = replaceImportPath(typeReference, hostFilename); + typeReference = replaceImportPath(typeReference, hostFilename, options); return factory.createPropertyAssignment( 'type', factory.createIdentifier(typeReference) @@ -279,10 +367,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor { } getStatusCodeIdentifier(factory: ts.NodeFactory, node: ts.MethodDeclaration) { - // Support both >= v4.8 and v4.7 and lower - const decorators = (ts as any).canHaveDecorators - ? (ts as any).getDecorators(node) - : (node as any).decorators; + const decorators = ts.canHaveDecorators(node) && ts.getDecorators(node); const httpCodeDecorator = getDecoratorOrUndefinedByNames( ['HttpCode'], decorators, @@ -304,4 +389,10 @@ export class ControllerClassVisitor extends AbstractFileVisitor { } return factory.createIdentifier('200'); } + + private normalizeImportPath(pathToSource: string, path: string) { + let relativePath = posix.relative(pathToSource, path); + relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; + return relativePath; + } } diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts index 279ef8fac..599ce411f 100644 --- a/lib/plugin/visitors/model-class.visitor.ts +++ b/lib/plugin/visitors/model-class.visitor.ts @@ -1,6 +1,7 @@ import { compact, flatten, head } from 'lodash'; +import { posix } from 'path'; import * as ts from 'typescript'; -import { factory, PropertyAssignment } from 'typescript'; +import { PropertyAssignment, factory } from 'typescript'; import { ApiHideProperty } from '../../decorators'; import { PluginOptions } from '../merge-options'; import { METADATA_FACTORY_NAME } from '../plugin-constants'; @@ -27,12 +28,25 @@ import { AbstractFileVisitor } from './abstract.visitor'; type ClassMetadata = Record; -const [tsVersionMajor, tsVersionMinor] = ts.versionMajorMinor - ?.split('.') - .map((x) => +x); -const isInUpdatedAstContext = tsVersionMinor >= 8 || tsVersionMajor > 4; - export class ModelClassVisitor extends AbstractFileVisitor { + private readonly _collectedMetadata: Record = {}; + + get collectedMetadata(): Array< + [ts.CallExpression, Record] + > { + const metadataWithImports = []; + Object.keys(this._collectedMetadata).forEach((filePath) => { + const metadata = this._collectedMetadata[filePath]; + const importExpr = ts.factory.createCallExpression( + ts.factory.createIdentifier('require'), + undefined, + [ts.factory.createStringLiteral(filePath)] + ); + metadataWithImports.push([importExpr, metadata]); + }); + return metadataWithImports; + } + visit( sourceFile: ts.SourceFile, ctx: ts.TransformationContext, @@ -45,68 +59,107 @@ export class ModelClassVisitor extends AbstractFileVisitor { const propertyNodeVisitorFactory = (metadata: ClassMetadata) => (node: ts.Node): ts.Node => { - if (ts.isPropertyDeclaration(node)) { - // Support both >= v4.8 and v4.7 and lower - const decorators = (ts as any).canHaveDecorators - ? (ts as any).getDecorators(node) - : (node as any).decorators; - - const hidePropertyDecorator = getDecoratorOrUndefinedByNames( - [ApiHideProperty.name], - decorators, - factory - ); - if (hidePropertyDecorator) { - return node; - } - - const isPropertyStatic = (node.modifiers || []).some( - (modifier: ts.Modifier) => - modifier.kind === ts.SyntaxKind.StaticKeyword - ); - if (isPropertyStatic) { - return node; - } - try { - this.inspectPropertyDeclaration( - ctx.factory, + const visit = () => { + if (ts.isPropertyDeclaration(node)) { + this.visitPropertyNodeDeclaration( node, + ctx, typeChecker, options, - sourceFile.fileName, sourceFile, metadata ); - } catch (err) { - return node; } + return node; + }; + const visitedNode = visit(); + if (!options.readonly) { + return visitedNode; } - return node; }; const visitClassNode = (node: ts.Node): ts.Node => { if (ts.isClassDeclaration(node)) { const metadata: ClassMetadata = {}; - node = ts.visitEachChild( - node, - propertyNodeVisitorFactory(metadata), - ctx - ); - return this.addMetadataFactory( + + if (options.readonly) { + ts.forEachChild(node, propertyNodeVisitorFactory(metadata)); + } else { + node = ts.visitEachChild( + node, + propertyNodeVisitorFactory(metadata), + ctx + ); + } + + const declaration = this.addMetadataFactory( ctx.factory, node as ts.ClassDeclaration, - metadata + metadata, + sourceFile, + options ); + + if (!options.readonly) { + return declaration; + } + } + + if (options.readonly) { + ts.forEachChild(node, visitClassNode); + } else { + return ts.visitEachChild(node, visitClassNode, ctx); } - return ts.visitEachChild(node, visitClassNode, ctx); }; return ts.visitNode(sourceFile, visitClassNode); } + visitPropertyNodeDeclaration( + node: ts.PropertyDeclaration, + ctx: ts.TransformationContext, + typeChecker: ts.TypeChecker, + options: PluginOptions, + sourceFile: ts.SourceFile, + metadata: ClassMetadata + ) { + const decorators = ts.canHaveDecorators(node) && ts.getDecorators(node); + + const hidePropertyDecorator = getDecoratorOrUndefinedByNames( + [ApiHideProperty.name], + decorators, + factory + ); + if (hidePropertyDecorator) { + return node; + } + + const isPropertyStatic = (node.modifiers || []).some( + (modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword + ); + if (isPropertyStatic) { + return node; + } + try { + this.inspectPropertyDeclaration( + ctx.factory, + node, + typeChecker, + options, + sourceFile.fileName, + sourceFile, + metadata + ); + } catch (err) { + return node; + } + } + addMetadataFactory( factory: ts.NodeFactory, node: ts.ClassDeclaration, - classMetadata: ClassMetadata + classMetadata: ClassMetadata, + sourceFile: ts.SourceFile, + options: PluginOptions ) { const returnValue = factory.createObjectLiteralExpression( Object.keys(classMetadata).map((key) => @@ -116,53 +169,39 @@ export class ModelClassVisitor extends AbstractFileVisitor { ) ) ); - const method = isInUpdatedAstContext - ? (factory as any).createMethodDeclaration( - [factory.createModifier(ts.SyntaxKind.StaticKeyword)], - undefined, - factory.createIdentifier(METADATA_FACTORY_NAME), - undefined, - undefined, - [], - undefined, - factory.createBlock( - [factory.createReturnStatement(returnValue)], - true - ) - ) - : (factory as any).createMethodDeclaration( - undefined, - [factory.createModifier(ts.SyntaxKind.StaticKeyword)], - undefined, - factory.createIdentifier(METADATA_FACTORY_NAME), - undefined, - undefined, - [], - undefined, - factory.createBlock( - [factory.createReturnStatement(returnValue)], - true - ) - ); - return isInUpdatedAstContext - ? (factory as any).updateClassDeclaration( - node, - node.modifiers, - node.name, - node.typeParameters, - node.heritageClauses, - [...node.members, method] - ) - : (factory as any).updateClassDeclaration( - node, - (node as any).decorators, - node.modifiers as any, - node.name, - node.typeParameters, - node.heritageClauses, - [...node.members, method] - ); + if (options.readonly) { + const filePath = this.normalizeImportPath( + options.pathToSource, + sourceFile.fileName + ); + if (!this._collectedMetadata[filePath]) { + this._collectedMetadata[filePath] = {}; + } + const attributeKey = node.name.getText(); + this._collectedMetadata[filePath][attributeKey] = returnValue; + return; + } + + const method = factory.createMethodDeclaration( + [factory.createModifier(ts.SyntaxKind.StaticKeyword)], + undefined, + factory.createIdentifier(METADATA_FACTORY_NAME), + undefined, + undefined, + [], + undefined, + factory.createBlock([factory.createReturnStatement(returnValue)], true) + ); + + return factory.updateClassDeclaration( + node, + node.modifiers, + node.name, + node.typeParameters, + node.heritageClauses, + [...node.members, method] + ); } inspectPropertyDeclaration( @@ -214,7 +253,8 @@ export class ModelClassVisitor extends AbstractFileVisitor { node.type, typeChecker, existingProperties, - hostFilename + hostFilename, + options ), ...this.createDescriptionAndTsDocTagPropertyAssigments( factory, @@ -230,7 +270,8 @@ export class ModelClassVisitor extends AbstractFileVisitor { node, typeChecker, existingProperties, - hostFilename + hostFilename, + options ) ]; if (options.classValidatorShim) { @@ -252,7 +293,8 @@ export class ModelClassVisitor extends AbstractFileVisitor { node: ts.TypeNode, typeChecker: ts.TypeChecker, existingProperties: ts.NodeArray, - hostFilename: string + hostFilename: string, + options: PluginOptions ): ts.PropertyAssignment[] { const key = 'type'; if (hasPropertyKey(key, existingProperties)) { @@ -309,7 +351,8 @@ export class ModelClassVisitor extends AbstractFileVisitor { remainingTypes[0], typeChecker, existingProperties, - hostFilename + hostFilename, + options ); const resultArray = new Array( @@ -335,7 +378,7 @@ export class ModelClassVisitor extends AbstractFileVisitor { if (!typeReference) { return []; } - typeReference = replaceImportPath(typeReference, hostFilename); + typeReference = replaceImportPath(typeReference, hostFilename, options); return [ factory.createPropertyAssignment( key, @@ -356,7 +399,8 @@ export class ModelClassVisitor extends AbstractFileVisitor { node: ts.PropertyDeclaration | ts.PropertySignature, typeChecker: ts.TypeChecker, existingProperties: ts.NodeArray, - hostFilename: string + hostFilename: string, + options: PluginOptions ) { const key = 'enum'; if (hasPropertyKey(key, existingProperties)) { @@ -393,7 +437,11 @@ export class ModelClassVisitor extends AbstractFileVisitor { isArrayType = typeIsArrayTuple.isArray; type = typeIsArrayTuple.type; } - const enumRef = replaceImportPath(getText(type, typeChecker), hostFilename); + const enumRef = replaceImportPath( + getText(type, typeChecker), + hostFilename, + options + ); const enumProperty = factory.createPropertyAssignment( key, factory.createIdentifier(enumRef) @@ -433,10 +481,7 @@ export class ModelClassVisitor extends AbstractFileVisitor { node: ts.PropertyDeclaration | ts.PropertySignature ): ts.PropertyAssignment[] { const assignments = []; - // Support both >= v4.8 and v4.7 and lower - const decorators = (ts as any).canHaveDecorators - ? (ts as any).getDecorators(node) - : (node as any).decorators; + const decorators = ts.canHaveDecorators(node) && ts.getDecorators(node); this.addPropertyByValidationDecorator( factory, @@ -549,7 +594,7 @@ export class ModelClassVisitor extends AbstractFileVisitor { factory: ts.NodeFactory, decoratorName: string, propertyKey: string, - decorators: ts.NodeArray, + decorators: readonly ts.Decorator[], assignments: ts.PropertyAssignment[] ) { this.addPropertiesByValidationDecorator( @@ -561,8 +606,14 @@ export class ModelClassVisitor extends AbstractFileVisitor { const argument: ts.Expression = head( getDecoratorArguments(decoratorRef) ); - if (argument) { - return [factory.createPropertyAssignment(propertyKey, argument)]; + const assignment = ts.isNumericLiteral(argument) + ? ts.factory.createNumericLiteral(argument.text) + : ts.isStringLiteral(argument) + ? ts.factory.createStringLiteral(argument.text) + : argument; + + if (assignment) { + return [factory.createPropertyAssignment(propertyKey, assignment)]; } return []; } @@ -572,7 +623,7 @@ export class ModelClassVisitor extends AbstractFileVisitor { addPropertiesByValidationDecorator( factory: ts.NodeFactory, decoratorName: string, - decorators: ts.NodeArray, + decorators: readonly ts.Decorator[], assignments: ts.PropertyAssignment[], addPropertyAssignments: (decoratorRef: ts.Decorator) => PropertyAssignment[] ) { @@ -662,4 +713,10 @@ export class ModelClassVisitor extends AbstractFileVisitor { return propertyAssignments; } + + private normalizeImportPath(pathToSource: string, path: string) { + let relativePath = posix.relative(pathToSource, path); + relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; + return relativePath; + } } diff --git a/lib/plugin/visitors/readonly.visitor.ts b/lib/plugin/visitors/readonly.visitor.ts new file mode 100644 index 000000000..bd9e1d5f0 --- /dev/null +++ b/lib/plugin/visitors/readonly.visitor.ts @@ -0,0 +1,50 @@ +import * as ts from 'typescript'; +import { PluginOptions, mergePluginOptions } from '../merge-options'; +import { isFilenameMatched } from '../utils/is-filename-matched.util'; +import { ControllerClassVisitor } from './controller-class.visitor'; +import { ModelClassVisitor } from './model-class.visitor'; + +export class ReadonlyVisitor { + public readonly key = '@nestjs/swagger'; + private readonly modelClassVisitor = new ModelClassVisitor(); + private readonly controllerClassVisitor = new ControllerClassVisitor(); + + constructor(private readonly options: PluginOptions) { + options.readonly = true; + + if (!options.pathToSource) { + throw new Error(`"pathToSource" must be defined in plugin options`); + } + } + + visit(program: ts.Program, sf: ts.SourceFile) { + const factoryHost = { factory: ts.factory } as any; + const parsedOptions: Record = mergePluginOptions(this.options); + + if (isFilenameMatched(parsedOptions.dtoFileNameSuffix, sf.fileName)) { + return this.modelClassVisitor.visit( + sf, + factoryHost, + program, + parsedOptions + ); + } + if ( + isFilenameMatched(parsedOptions.controllerFileNameSuffix, sf.fileName) + ) { + return this.controllerClassVisitor.visit( + sf, + factoryHost, + program, + parsedOptions + ); + } + } + + collect() { + return { + models: this.modelClassVisitor.collectedMetadata, + controllers: this.controllerClassVisitor.collectedMetadata + }; + } +} diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts index 54cc7126f..18807bb6b 100644 --- a/lib/swagger-module.ts +++ b/lib/swagger-module.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { HttpServer } from '@nestjs/common/interfaces/http/http-server.interface'; +import { isFunction } from '@nestjs/common/utils/shared.utils'; import { NestExpressApplication } from '@nestjs/platform-express'; import { NestFastifyApplication } from '@nestjs/platform-fastify'; import * as jsyaml from 'js-yaml'; @@ -8,6 +9,7 @@ import { SwaggerCustomOptions, SwaggerDocumentOptions } from './interfaces'; +import { MetadataLoader } from './plugin/metadata-loader'; import { SwaggerScanner } from './swagger-scanner'; import { buildSwaggerHTML, @@ -16,11 +18,13 @@ import { } from './swagger-ui'; import { assignTwoLevelsDeep } from './utils/assign-two-levels-deep'; import { getGlobalPrefix } from './utils/get-global-prefix'; -import { validatePath } from './utils/validate-path.util'; import { normalizeRelPath } from './utils/normalize-rel-path'; import { validateGlobalPrefix } from './utils/validate-global-prefix.util'; +import { validatePath } from './utils/validate-path.util'; export class SwaggerModule { + private static readonly metadataLoader = new MetadataLoader(); + public static createDocument( app: INestApplication, config: Omit, @@ -43,6 +47,10 @@ export class SwaggerModule { }; } + public static async loadPluginMetadata(metadata: Record) { + return this.metadataLoader.load(metadata); + } + private static serveStatic(finalPath: string, app: INestApplication) { const httpAdapter = app.getHttpAdapter(); const swaggerAssetsAbsoluteFSPath = getSwaggerAssetsAbsoluteFSPath(); @@ -65,21 +73,39 @@ export class SwaggerModule { finalPath: string, urlLastSubdirectory: string, httpAdapter: HttpServer, - swaggerInitJS: string, + documentOrFactory: OpenAPIObject | (() => OpenAPIObject), options: { - html: string; - yamlDocument: string; - jsonDocument: string; jsonDocumentUrl: string; yamlDocumentUrl: string; - } + swaggerOptions: SwaggerCustomOptions + }, ) { + let document: OpenAPIObject; + + const lazyBuildDocument = () => { + return typeof documentOrFactory === 'function' ? documentOrFactory() : documentOrFactory; + } + + const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`); + + let html: string; + let swaggerInitJS: string; + httpAdapter.get( normalizeRelPath(`${finalPath}/swagger-ui-init.js`), (req, res) => { res.type('application/javascript'); + + if (!document) { + document = lazyBuildDocument(); + } + + if (!swaggerInitJS) { + swaggerInitJS = buildSwaggerInitJS(document, options); + } + res.send(swaggerInitJS); - } + }, ); /** @@ -89,12 +115,21 @@ export class SwaggerModule { try { httpAdapter.get( normalizeRelPath( - `${finalPath}/${urlLastSubdirectory}/swagger-ui-init.js` + `${finalPath}/${urlLastSubdirectory}/swagger-ui-init.js`, ), (req, res) => { res.type('application/javascript'); + + if (!document) { + document = lazyBuildDocument(); + } + + if (!swaggerInitJS) { + swaggerInitJS = buildSwaggerInitJS(document, options); + } + res.send(swaggerInitJS); - } + }, ); } catch (err) { /** @@ -105,14 +140,32 @@ export class SwaggerModule { httpAdapter.get(finalPath, (req, res) => { res.type('text/html'); - res.send(options.html); + + if (!document) { + document = lazyBuildDocument(); + } + + if (!html) { + html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options); + } + + res.send(html); }); // fastify doesn't resolve 'routePath/' -> 'routePath', that's why we handle it manually try { httpAdapter.get(normalizeRelPath(`${finalPath}/`), (req, res) => { res.type('text/html'); - res.send(options.html); + + if (!document) { + document = lazyBuildDocument(); + } + + if (!html) { + html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options); + } + + res.send(html); }); } catch (err) { /** @@ -123,33 +176,51 @@ export class SwaggerModule { */ } + let yamlDocument: string; + let jsonDocument: string; + httpAdapter.get(normalizeRelPath(options.jsonDocumentUrl), (req, res) => { res.type('application/json'); - res.send(options.jsonDocument); + + if (!document) { + document = lazyBuildDocument(); + } + + if (!jsonDocument) { + jsonDocument = JSON.stringify(document); + } + + res.send(jsonDocument); }); httpAdapter.get(normalizeRelPath(options.yamlDocumentUrl), (req, res) => { res.type('text/yaml'); - res.send(options.yamlDocument); + + if (!document) { + document = lazyBuildDocument(); + } + + if (!yamlDocument) { + yamlDocument = jsyaml.dump(document, { skipInvalid: true }); + } + + res.send(yamlDocument); }); } public static setup( path: string, app: INestApplication, - document: OpenAPIObject, + documentOrFactory: OpenAPIObject | (() => OpenAPIObject), options?: SwaggerCustomOptions ) { const globalPrefix = getGlobalPrefix(app); const finalPath = validatePath( options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix) ? `${globalPrefix}${validatePath(path)}` - : path + : path, ); - const urlLastSubdirectory = finalPath.split('/').slice(-1).pop(); - - const yamlDocument = jsyaml.dump(document, { skipInvalid: true }); - const jsonDocument = JSON.stringify(document); + const urlLastSubdirectory = finalPath.split('/').slice(-1).pop() || ''; const validatedGlobalPrefix = options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix) @@ -164,24 +235,18 @@ export class SwaggerModule { ? `${validatedGlobalPrefix}${validatePath(options.yamlDocumentUrl)}` : `${finalPath}-yaml`; - const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`); - - const html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options); - const swaggerInitJS = buildSwaggerInitJS(document, options); const httpAdapter = app.getHttpAdapter(); SwaggerModule.serveDocuments( finalPath, urlLastSubdirectory, httpAdapter, - swaggerInitJS, + documentOrFactory, { - html, - yamlDocument, - jsonDocument, jsonDocumentUrl: finalJSONDocumentPath, - yamlDocumentUrl: finalYAMLDocumentPath - } + yamlDocumentUrl: finalYAMLDocumentPath, + swaggerOptions: options || {}, + }, ); SwaggerModule.serveStatic(finalPath, app); diff --git a/package-lock.json b/package-lock.json index 6237ac33d..1a7a58a46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11435,9 +11435,9 @@ } }, "swagger-ui-dist": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.19.0.tgz", - "integrity": "sha512-9C9fJGI18gK5AhaU5YRyPY1lXJH4lmWh8h9zFMrJBkYzdRjCbAzYl1ayWPYgwFvag/Luqi3Co599OK/39IS2QQ==" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.0.0.tgz", + "integrity": "sha512-bwl6og9I9CAHKGSnYLKydjhBuH7d3oU6RX6uKN8oDCkLusTHXOW3sZMyBWjRtjGFnCMmN085oZoaR/4Wm9nIaQ==" }, "test-exclude": { "version": "6.0.0", diff --git a/package.json b/package.json index bf9745bb4..dbfbe9492 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "js-yaml": "4.1.0", "lodash": "4.17.21", "path-to-regexp": "3.2.0", - "swagger-ui-dist": "4.19.0" + "swagger-ui-dist": "5.0.0" }, "devDependencies": { "@commitlint/cli": "17.6.5", @@ -64,8 +64,8 @@ }, "peerDependencies": { "@fastify/static": "^6.0.0", - "@nestjs/common": "^9.0.0", - "@nestjs/core": "^9.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12" diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts index 846455600..c1b6a1fbd 100644 --- a/test/explorer/swagger-explorer.spec.ts +++ b/test/explorer/swagger-explorer.spec.ts @@ -9,7 +9,7 @@ import { Version, VersioningType } from '@nestjs/common'; -import { VersionValue, VERSION_NEUTRAL } from '@nestjs/common/interfaces'; +import { VERSION_NEUTRAL, VersionValue } from '@nestjs/common/interfaces'; import { ApplicationConfig } from '@nestjs/core'; import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; import { @@ -30,11 +30,12 @@ import { } from '../../lib/decorators'; import { DenormalizedDoc } from '../../lib/interfaces/denormalized-doc.interface'; import { ResponseObject } from '../../lib/interfaces/open-api-spec.interface'; +import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants'; import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; import { SchemaObjectFactory } from '../../lib/services/schema-object-factory'; import { SwaggerTypesMapper } from '../../lib/services/swagger-types-mapper'; -import { SwaggerExplorer } from '../../lib/swagger-explorer'; import { GlobalParametersStorage } from '../../lib/storages/global-parameters.storage'; +import { SwaggerExplorer } from '../../lib/swagger-explorer'; describe('SwaggerExplorer', () => { const schemaObjectFactory = new SchemaObjectFactory( @@ -124,6 +125,57 @@ describe('SwaggerExplorer', () => { validateRoutes(routes, operationPrefix); }); + @Controller('') + class FooWithMetadataController { + @Post('foos') + @ApiCreatedResponse({ + type: Foo, + description: 'Newly created Foo object' + }) + create( + @Body() createFoo: CreateFoo, + @Query() listEntities: ListEntitiesDto + ): Promise { + return Promise.resolve({}); + } + + @Get(['foos/:objectId', 'foo/:objectId']) + find( + @Param('objectId') objectId: string, + @Query('page') q: string + ): Promise { + return Promise.resolve([]); + } + + static [METADATA_FACTORY_NAME]() { + return { + create: { + summary: 'Create foo' + }, + find: { + summary: 'List all Foos', + type: [Foo] + } + }; + } + } + + it('sees two controller operations and their responses (metadata cache)', () => { + const explorer = new SwaggerExplorer(schemaObjectFactory); + const routes = explorer.exploreController( + { + instance: new FooWithMetadataController(), + metatype: FooWithMetadataController + } as unknown as InstanceWrapper, + new ApplicationConfig(), + 'modulePath', + 'globalPrefix' + ); + const operationPrefix = 'FooWithMetadataController_'; + + validateRoutes(routes, operationPrefix); + }); + it('sees two controller operations and their responses with custom operationIdFactory to return methodKey', () => { const explorer = new SwaggerExplorer(schemaObjectFactory); const routes = explorer.exploreController( @@ -1466,12 +1518,10 @@ describe('SwaggerExplorer', () => { }); }); }); - - describe('when @All(...) is used', () => { + describe('when @All(...) is used', () => { @Controller('') class AllController { - @All('*') all(): Promise { return Promise.resolve(); @@ -1491,13 +1541,26 @@ describe('SwaggerExplorer', () => { ); expect(routes.length).toEqual(7); - expect(['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every((method) => routes.find((route) => route.root.method === method))).toBe(true); - expect(routes.find((route) => route.root.method === 'all')).toBe(undefined); + expect( + ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every( + (method) => routes.find((route) => route.root.method === method) + ) + ).toBe(true); + expect(routes.find((route) => route.root.method === 'all')).toBe( + undefined + ); // check if all routes are equal except for method - expect(routes.filter((v, i, a) => a.findIndex(v2 => ['path', 'parameter'].every(k => v2[k] === v[k])) === i).length).toEqual(1); + expect( + routes.filter( + (v, i, a) => + a.findIndex((v2) => + ['path', 'parameter'].every((k) => v2[k] === v[k]) + ) === i + ).length + ).toEqual(1); }); }); - + describe('when global paramters are defined', () => { class Foo {} diff --git a/tsconfig.json b/tsconfig.json index c40afb946..34e051061 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "noLib": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "target": "es6", + "target": "ES2021", "sourceMap": false, "outDir": "./dist", "rootDir": "./lib",