diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts index ca514e0c2..c6c130da2 100644 --- a/e2e/validate-schema.e2e-spec.ts +++ b/e2e/validate-schema.e2e-spec.ts @@ -49,48 +49,50 @@ 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'] - } + await SwaggerModule.loadPluginMetadata(async () => ({ + '@nestjs/swagger': { + models: [ + [ + import('./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' - } + } + ], + [ + import('./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' - } + } + ] + ], + controllers: [ + [ + import('./src/cats/cats.controller'), + { + CatsController: { + findAllBulk: { + type: [ + await import('./src/cats/classes/cat.class').then( + (f) => f.Cat + ) + ], + summary: 'Find all cats in bulk' } } - ] + } ] - } + ] } - }); + })); const document = SwaggerModule.createDocument(app, options); const doc = JSON.stringify(document, null, 2); diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts index 154cc45eb..0217c4b90 100644 --- a/lib/plugin/utils/plugin-utils.ts +++ b/lib/plugin/utils/plugin-utils.ts @@ -33,56 +33,71 @@ export function getDecoratorOrUndefinedByNames( export function getTypeReferenceAsString( type: ts.Type, - typeChecker: ts.TypeChecker -): string { + typeChecker: ts.TypeChecker, + arrayDepth = 0 +): { + typeName: string; + isArray?: boolean; + arrayDepth?: number; +} { if (isArray(type)) { const arrayType = getTypeArguments(type)[0]; - const elementType = getTypeReferenceAsString(arrayType, typeChecker); - if (!elementType) { - return undefined; + const { typeName, arrayDepth: depth } = getTypeReferenceAsString( + arrayType, + typeChecker, + arrayDepth + 1 + ); + if (!typeName) { + return { typeName: undefined }; } - return `[${elementType}]`; + return { + typeName: `${typeName}`, + isArray: true, + arrayDepth: depth + }; } if (isBoolean(type)) { - return Boolean.name; + return { typeName: Boolean.name, arrayDepth }; } if (isNumber(type)) { - return Number.name; + return { typeName: Number.name, arrayDepth }; } if (isBigInt(type)) { - return BigInt.name; + return { typeName: BigInt.name, arrayDepth }; } if (isString(type) || isStringLiteral(type)) { - return String.name; + return { typeName: String.name, arrayDepth }; } if (isPromiseOrObservable(getText(type, typeChecker))) { const typeArguments = getTypeArguments(type); const elementType = getTypeReferenceAsString( head(typeArguments), - typeChecker + typeChecker, + arrayDepth ); - if (!elementType) { - return undefined; - } return elementType; } if (type.isClass()) { - return getText(type, typeChecker); + return { typeName: getText(type, typeChecker), arrayDepth }; } try { const text = getText(type, typeChecker); if (text === Date.name) { - return text; + return { typeName: text, arrayDepth }; } if (isOptionalBoolean(text)) { - return Boolean.name; + return { typeName: Boolean.name, arrayDepth }; } if ( isAutoGeneratedTypeUnion(type) || isAutoGeneratedEnumUnion(type, typeChecker) ) { const types = (type as ts.UnionOrIntersectionType).types; - return getTypeReferenceAsString(types[types.length - 1], typeChecker); + return getTypeReferenceAsString( + types[types.length - 1], + typeChecker, + arrayDepth + ); } if ( text === 'any' || @@ -91,17 +106,17 @@ export function getTypeReferenceAsString( isInterface(type) || (type.isUnionOrIntersection() && !isEnum(type)) ) { - return 'Object'; + return { typeName: 'Object', arrayDepth }; } if (isEnum(type)) { - return undefined; + return { typeName: undefined, arrayDepth }; } if (type.aliasSymbol) { - return 'Object'; + return { typeName: 'Object', arrayDepth }; } - return undefined; + return { typeName: undefined }; } catch { - return undefined; + return { typeName: undefined }; } } @@ -124,11 +139,11 @@ export function replaceImportPath( options: PluginOptions ) { if (!typeReference.includes('import')) { - return typeReference; + return { typeReference, importPath: null }; } let importPath = /\(\"([^)]).+(\")/.exec(typeReference)[0]; if (!importPath) { - return undefined; + return { typeReference: undefined, importPath: null }; } importPath = convertPath(importPath); importPath = importPath.slice(2, importPath.length - 1); @@ -138,11 +153,16 @@ export function replaceImportPath( throw {}; } require.resolve(importPath); - return typeReference.replace('import', 'require'); + typeReference = typeReference.replace('import', 'require'); + return { + typeReference, + importPath: null + }; } catch (_error) { const from = options?.readonly ? options.pathToSource : posix.dirname(fileName); + let relativePath = posix.relative(from, importPath); relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; @@ -169,16 +189,42 @@ export function replaceImportPath( } typeReference = typeReference.replace(importPath, relativePath); - return typeReference.replace('import', 'require'); + + return { + typeReference: options.readonly + ? convertToAsyncImport(typeReference) + : typeReference.replace('import', 'require'), + importPath: relativePath + }; } } +function convertToAsyncImport(typeReference: string) { + const regexp = /import\(.+\).([^\]]+)(\])?/; + const match = regexp.exec(typeReference); + + if (match?.length >= 2) { + const importPos = typeReference.indexOf(match[0]); + typeReference = typeReference.replace( + match[1], + `then((f) => f.${match[1]})` + ); + return insertAt(typeReference, importPos, 'await '); + } + + return typeReference; +} + +export function insertAt(string: string, index: number, substring: string) { + return string.slice(0, index) + substring + string.slice(index); +} + export function isDynamicallyAdded(identifier: ts.Node) { return identifier && !identifier.parent && identifier.pos === -1; } /** - * when "strict" mode enabled, TypeScript transform the enum type to a union composed of + * When "strict" mode enabled, TypeScript transform the enum type to a union composed of * the enum values and the undefined type. Hence, we have to lookup all the union types to get the original type * @param type * @param typeChecker diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts index 76745f316..60a1a3057 100644 --- a/lib/plugin/visitors/controller-class.visitor.ts +++ b/lib/plugin/visitors/controller-class.visitor.ts @@ -26,6 +26,11 @@ export class ControllerClassVisitor extends AbstractFileVisitor { string, Record > = {}; + private readonly _typeImports: Record = {}; + + get typeImports() { + return this._typeImports; + } get collectedMetadata(): Array< [ts.CallExpression, Record] @@ -340,18 +345,21 @@ export class ControllerClassVisitor extends AbstractFileVisitor { if (!type) { return undefined; } - let typeReference = getTypeReferenceAsString(type, typeChecker); - if (!typeReference) { + const { typeName, isArray } = getTypeReferenceAsString(type, typeChecker); + if (!typeName) { return undefined; } - if (typeReference.includes('node_modules')) { + if (typeName.includes('node_modules')) { return undefined; } - typeReference = replaceImportPath(typeReference, hostFilename, options); - return factory.createPropertyAssignment( - 'type', - factory.createIdentifier(typeReference) + const identifier = this.typeReferenceStringToIdentifier( + typeName, + isArray, + hostFilename, + options, + factory ); + return factory.createPropertyAssignment('type', identifier); } createStatusPropertyAssignment( @@ -395,4 +403,34 @@ export class ControllerClassVisitor extends AbstractFileVisitor { relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; return relativePath; } + + private typeReferenceStringToIdentifier( + _typeReference: string, + isArray: boolean, + hostFilename: string, + options: PluginOptions, + factory: ts.NodeFactory + ) { + const { typeReference, importPath } = replaceImportPath( + _typeReference, + hostFilename, + options + ); + + let identifier: ts.Identifier; + if (options.readonly && typeReference?.includes('import')) { + if (!this._typeImports[importPath]) { + this._typeImports[importPath] = typeReference; + } + + identifier = factory.createIdentifier( + isArray ? `[t["${importPath}"]]` : `t["${importPath}"]` + ); + } else { + identifier = factory.createIdentifier( + isArray ? `[${typeReference}]` : typeReference + ); + } + return identifier; + } } diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts index 599ce411f..f57b90bbf 100644 --- a/lib/plugin/visitors/model-class.visitor.ts +++ b/lib/plugin/visitors/model-class.visitor.ts @@ -29,18 +29,24 @@ import { AbstractFileVisitor } from './abstract.visitor'; type ClassMetadata = Record; export class ModelClassVisitor extends AbstractFileVisitor { + private readonly _typeImports: Record = {}; private readonly _collectedMetadata: Record = {}; + get typeImports() { + return this._typeImports; + } + 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.createIdentifier('require'), + ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression, undefined, - [ts.factory.createStringLiteral(filePath)] + [ts.factory.createStringLiteral(path)] ); metadataWithImports.push([importExpr, metadata]); }); @@ -374,24 +380,26 @@ export class ModelClassVisitor extends AbstractFileVisitor { if (!type) { return []; } - let typeReference = getTypeReferenceAsString(type, typeChecker); - if (!typeReference) { + const typeReferenceDescriptor = getTypeReferenceAsString(type, typeChecker); + if (!typeReferenceDescriptor.typeName) { return []; } - typeReference = replaceImportPath(typeReference, hostFilename, options); - return [ - factory.createPropertyAssignment( - key, - factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - undefined, - factory.createIdentifier(typeReference) - ) - ) - ]; + const identifier = this.typeReferenceToIdentifier( + typeReferenceDescriptor, + hostFilename, + options, + factory + ); + + const initializer = factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + undefined, + identifier + ); + return [factory.createPropertyAssignment(key, initializer)]; } createEnumPropertyAssignment( @@ -437,15 +445,17 @@ export class ModelClassVisitor extends AbstractFileVisitor { isArrayType = typeIsArrayTuple.isArray; type = typeIsArrayTuple.type; } - const enumRef = replaceImportPath( - getText(type, typeChecker), + + const typeReferenceDescriptor = { typeName: getText(type, typeChecker) }; + const enumIdentifier = this.typeReferenceToIdentifier( + typeReferenceDescriptor, hostFilename, - options - ); - const enumProperty = factory.createPropertyAssignment( - key, - factory.createIdentifier(enumRef) + options, + factory ); + + const enumProperty = factory.createPropertyAssignment(key, enumIdentifier); + if (isArrayType) { const isArrayKey = 'isArray'; const isArrayProperty = factory.createPropertyAssignment( @@ -719,4 +729,48 @@ export class ModelClassVisitor extends AbstractFileVisitor { relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; return relativePath; } + + private typeReferenceToIdentifier( + typeReferenceDescriptor: { + typeName: string; + isArray?: boolean; + arrayDepth?: number; + }, + hostFilename: string, + options: PluginOptions, + factory: ts.NodeFactory + ) { + const { typeReference, importPath } = replaceImportPath( + typeReferenceDescriptor.typeName, + hostFilename, + options + ); + + let identifier: ts.Identifier; + if (options.readonly && typeReference?.includes('import')) { + if (!this._typeImports[importPath]) { + this._typeImports[importPath] = typeReference; + } + + let ref = `t["${importPath}"]`; + if (typeReferenceDescriptor.isArray) { + ref = this.wrapTypeInArray(ref, typeReferenceDescriptor.arrayDepth); + } + identifier = factory.createIdentifier(ref); + } else { + let ref = typeReference; + if (typeReferenceDescriptor.isArray) { + ref = this.wrapTypeInArray(ref, typeReferenceDescriptor.arrayDepth); + } + identifier = factory.createIdentifier(ref); + } + return identifier; + } + + private wrapTypeInArray(typeRef: string, arrayDepth: number) { + for (let i = 0; i < arrayDepth; i++) { + typeRef = `[${typeRef}]`; + } + return typeRef; + } } diff --git a/lib/plugin/visitors/readonly.visitor.ts b/lib/plugin/visitors/readonly.visitor.ts index bd9e1d5f0..82634e337 100644 --- a/lib/plugin/visitors/readonly.visitor.ts +++ b/lib/plugin/visitors/readonly.visitor.ts @@ -9,6 +9,10 @@ export class ReadonlyVisitor { private readonly modelClassVisitor = new ModelClassVisitor(); private readonly controllerClassVisitor = new ControllerClassVisitor(); + get typeImports() { + return this.modelClassVisitor.typeImports; + } + constructor(private readonly options: PluginOptions) { options.readonly = true; diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts index 18807bb6b..7c6d8fc8f 100644 --- a/lib/swagger-module.ts +++ b/lib/swagger-module.ts @@ -1,6 +1,5 @@ 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'; @@ -47,7 +46,10 @@ export class SwaggerModule { }; } - public static async loadPluginMetadata(metadata: Record) { + public static async loadPluginMetadata( + metadataFn: () => Promise> + ) { + const metadata = await metadataFn(); return this.metadataLoader.load(metadata); } @@ -77,14 +79,16 @@ export class SwaggerModule { options: { jsonDocumentUrl: string; yamlDocumentUrl: string; - swaggerOptions: SwaggerCustomOptions - }, + swaggerOptions: SwaggerCustomOptions; + } ) { let document: OpenAPIObject; const lazyBuildDocument = () => { - return typeof documentOrFactory === 'function' ? documentOrFactory() : documentOrFactory; - } + return typeof documentOrFactory === 'function' + ? documentOrFactory() + : documentOrFactory; + }; const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`); @@ -105,7 +109,7 @@ export class SwaggerModule { } res.send(swaggerInitJS); - }, + } ); /** @@ -115,7 +119,7 @@ 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'); @@ -129,7 +133,7 @@ export class SwaggerModule { } res.send(swaggerInitJS); - }, + } ); } catch (err) { /** @@ -183,11 +187,11 @@ export class SwaggerModule { res.type('application/json'); if (!document) { - document = lazyBuildDocument(); + document = lazyBuildDocument(); } if (!jsonDocument) { - jsonDocument = JSON.stringify(document); + jsonDocument = JSON.stringify(document); } res.send(jsonDocument); @@ -201,7 +205,7 @@ export class SwaggerModule { } if (!yamlDocument) { - yamlDocument = jsyaml.dump(document, { skipInvalid: true }); + yamlDocument = jsyaml.dump(document, { skipInvalid: true }); } res.send(yamlDocument); @@ -218,7 +222,7 @@ export class SwaggerModule { const finalPath = validatePath( options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix) ? `${globalPrefix}${validatePath(path)}` - : path, + : path ); const urlLastSubdirectory = finalPath.split('/').slice(-1).pop() || ''; @@ -245,8 +249,8 @@ export class SwaggerModule { { jsonDocumentUrl: finalJSONDocumentPath, yamlDocumentUrl: finalYAMLDocumentPath, - swaggerOptions: options || {}, - }, + swaggerOptions: options || {} + } ); SwaggerModule.serveStatic(finalPath, app); diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts index c1b6a1fbd..5c37b2f70 100644 --- a/test/explorer/swagger-explorer.spec.ts +++ b/test/explorer/swagger-explorer.spec.ts @@ -217,12 +217,12 @@ describe('SwaggerExplorer', () => { expect(routes.length).toEqual(3); // POST - expect(routes[0].root.operationId).toEqual(operationPrefix + 'create'); - expect(routes[0].root.method).toEqual('post'); - expect(routes[0].root.path).toEqual('/globalPrefix/modulePath/foos'); - expect(routes[0].root.summary).toEqual('Create foo'); - expect(routes[0].root.parameters.length).toEqual(5); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.operationId).toEqual(operationPrefix + 'create'); + expect(routes[0].root!.method).toEqual('post'); + expect(routes[0].root!.path).toEqual('/globalPrefix/modulePath/foos'); + expect(routes[0].root!.summary).toEqual('Create foo'); + expect(routes[0].root!.parameters.length).toEqual(5); + expect(routes[0].root!.parameters).toEqual([ { in: 'query', name: 'page', @@ -272,7 +272,7 @@ describe('SwaggerExplorer', () => { } } ]); - expect(routes[0].root.requestBody).toEqual({ + expect(routes[0].root!.requestBody).toEqual({ required: true, content: { 'application/json': { @@ -297,14 +297,14 @@ describe('SwaggerExplorer', () => { }); // GET - expect(routes[1].root.operationId).toEqual(operationPrefix + 'find'); - expect(routes[1].root.method).toEqual('get'); - expect(routes[1].root.path).toEqual( + expect(routes[1].root!.operationId).toEqual(operationPrefix + 'find'); + expect(routes[1].root!.method).toEqual('get'); + expect(routes[1].root!.path).toEqual( '/globalPrefix/modulePath/foos/{objectId}' ); - expect(routes[1].root.summary).toEqual('List all Foos'); - expect(routes[1].root.parameters.length).toEqual(2); - expect(routes[1].root.parameters).toEqual([ + expect(routes[1].root!.summary).toEqual('List all Foos'); + expect(routes[1].root!.parameters.length).toEqual(2); + expect(routes[1].root!.parameters).toEqual([ { in: 'path', name: 'objectId', @@ -339,14 +339,14 @@ describe('SwaggerExplorer', () => { }); // GET alias - expect(routes[2].root.operationId).toEqual(operationPrefix + 'find'); - expect(routes[2].root.method).toEqual('get'); - expect(routes[2].root.path).toEqual( + expect(routes[2].root!.operationId).toEqual(operationPrefix + 'find'); + expect(routes[2].root!.method).toEqual('get'); + expect(routes[2].root!.path).toEqual( '/globalPrefix/modulePath/foo/{objectId}' ); - expect(routes[2].root.summary).toEqual('List all Foos'); - expect(routes[2].root.parameters.length).toEqual(2); - expect(routes[2].root.parameters).toEqual([ + expect(routes[2].root!.summary).toEqual('List all Foos'); + expect(routes[2].root!.parameters.length).toEqual(2); + expect(routes[2].root!.parameters).toEqual([ { in: 'path', name: 'objectId', @@ -472,12 +472,12 @@ describe('SwaggerExplorer', () => { expect(routes.length).toEqual(2); // POST - expect(routes[0].root.operationId).toEqual(operationPrefix + 'create'); - expect(routes[0].root.method).toEqual('post'); - expect(routes[0].root.path).toEqual('/globalPrefix/foos'); - expect(routes[0].root.summary).toEqual('Create foo'); - expect(routes[0].root.parameters.length).toEqual(0); - expect(routes[0].root.requestBody).toEqual({ + expect(routes[0].root!.operationId).toEqual(operationPrefix + 'create'); + expect(routes[0].root!.method).toEqual('post'); + expect(routes[0].root!.path).toEqual('/globalPrefix/foos'); + expect(routes[0].root!.summary).toEqual('Create foo'); + expect(routes[0].root!.parameters.length).toEqual(0); + expect(routes[0].root!.requestBody).toEqual({ required: true, content: { 'application/json': { @@ -505,12 +505,12 @@ describe('SwaggerExplorer', () => { }); // GET - expect(routes[1].root.operationId).toEqual(operationPrefix + 'find'); - expect(routes[1].root.method).toEqual('get'); - expect(routes[1].root.path).toEqual('/globalPrefix/foos/{objectId}'); - expect(routes[1].root.summary).toEqual('List all Foos'); - expect(routes[1].root.parameters.length).toEqual(2); - expect(routes[1].root.parameters).toEqual([ + expect(routes[1].root!.operationId).toEqual(operationPrefix + 'find'); + expect(routes[1].root!.method).toEqual('get'); + expect(routes[1].root!.path).toEqual('/globalPrefix/foos/{objectId}'); + expect(routes[1].root!.summary).toEqual('List all Foos'); + expect(routes[1].root!.parameters.length).toEqual(2); + expect(routes[1].root!.parameters).toEqual([ { in: 'path', name: 'objectId', @@ -645,12 +645,12 @@ describe('SwaggerExplorer', () => { expect(routes.length).toEqual(2); // POST - expect(routes[0].root.operationId).toEqual(operationPrefix + 'create'); - expect(routes[0].root.method).toEqual('post'); - expect(routes[0].root.path).toEqual('/modulePath/foos'); - expect(routes[0].root.summary).toEqual('Create foo'); - expect(routes[0].root.parameters.length).toEqual(0); - expect(routes[0].root.requestBody).toEqual({ + expect(routes[0].root!.operationId).toEqual(operationPrefix + 'create'); + expect(routes[0].root!.method).toEqual('post'); + expect(routes[0].root!.path).toEqual('/modulePath/foos'); + expect(routes[0].root!.summary).toEqual('Create foo'); + expect(routes[0].root!.parameters.length).toEqual(0); + expect(routes[0].root!.requestBody).toEqual({ required: true, content: { 'application/xml': { @@ -675,12 +675,12 @@ describe('SwaggerExplorer', () => { }); // GET - expect(routes[1].root.operationId).toEqual(operationPrefix + 'find'); - expect(routes[1].root.method).toEqual('get'); - expect(routes[1].root.path).toEqual('/modulePath/foos/{objectId}'); - expect(routes[1].root.summary).toEqual('List all Foos'); - expect(routes[1].root.parameters.length).toEqual(2); - expect(routes[1].root.parameters).toEqual([ + expect(routes[1].root!.operationId).toEqual(operationPrefix + 'find'); + expect(routes[1].root!.method).toEqual('get'); + expect(routes[1].root!.path).toEqual('/modulePath/foos/{objectId}'); + expect(routes[1].root!.summary).toEqual('List all Foos'); + expect(routes[1].root!.parameters.length).toEqual(2); + expect(routes[1].root!.parameters).toEqual([ { in: 'query', name: 'page', @@ -815,11 +815,11 @@ describe('SwaggerExplorer', () => { expect(routes.length).toEqual(2); // POST - expect(routes[0].root.description).toEqual('Allows creating Foo item'); - expect(routes[0].root.tags).toEqual(['foo']); - expect(routes[0].root.operationId).toEqual('FooController_create2'); - expect(routes[0].root.parameters.length).toEqual(0); - expect(routes[0].root.requestBody).toEqual({ + expect(routes[0].root!.description).toEqual('Allows creating Foo item'); + expect(routes[0].root!.tags).toEqual(['foo']); + expect(routes[0].root!.operationId).toEqual('FooController_create2'); + expect(routes[0].root!.parameters.length).toEqual(0); + expect(routes[0].root!.requestBody).toEqual({ required: true, content: { 'application/xml': { @@ -848,12 +848,12 @@ describe('SwaggerExplorer', () => { }); // GET - expect(routes[1].root.path).toEqual( + expect(routes[1].root!.path).toEqual( '/globalPrefix/v2/modulePath/foos/{objectId}' ); - expect(routes[1].root.operationId).toEqual('FooController_find2'); - expect(routes[1].root.parameters.length).toEqual(2); - expect(routes[1].root.parameters).toEqual([ + expect(routes[1].root!.operationId).toEqual('FooController_find2'); + expect(routes[1].root!.parameters.length).toEqual(2); + expect(routes[1].root!.parameters).toEqual([ { in: 'query', name: 'page', @@ -874,7 +874,7 @@ describe('SwaggerExplorer', () => { } } ]); - expect(routes[1].root.requestBody).toEqual({ + expect(routes[1].root!.requestBody).toEqual({ required: true, content: { 'application/json': { @@ -998,10 +998,10 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.path).toEqual( + expect(routes[0].root!.path).toEqual( '/globalPrefix/v3/modulePath/foos/{objectId}' ); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.parameters).toEqual([ { in: 'query', name: 'page', @@ -1046,7 +1046,7 @@ describe('SwaggerExplorer', () => { 'path' ); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.parameters).toEqual([ { in: 'query', name: 'page', @@ -1089,7 +1089,7 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.parameters).toEqual([ { in: 'query', name: 'page', @@ -1135,7 +1135,7 @@ describe('SwaggerExplorer', () => { ); expect(schema.NumberEnum).toEqual({ type: 'number', enum: [1, 2, 3] }); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.parameters).toEqual([ { in: 'path', name: 'objectId', @@ -1187,7 +1187,7 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.parameters).toEqual([ { description: 'auth token', name: 'Authorization', @@ -1206,7 +1206,7 @@ describe('SwaggerExplorer', () => { } } ]); - expect(routes[1].root.parameters).toEqual([ + expect(routes[1].root!.parameters).toEqual([ { description: 'auth token', name: 'Authorization', @@ -1366,7 +1366,7 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.path).toEqual( + expect(routes[0].root!.path).toEqual( `/globalPrefix/v${CONTROLLER_VERSION}/modulePath/with-version` ); }); @@ -1382,7 +1382,7 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[1].root.path).toEqual( + expect(routes[1].root!.path).toEqual( `/globalPrefix/v${METHOD_VERSION}/modulePath/with-version` ); }); @@ -1398,12 +1398,12 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.path).toEqual( + expect(routes[0].root!.path).toEqual( `/globalPrefix/v${ CONTROLLER_MULTIPLE_VERSIONS[0] as string }/modulePath/with-multiple-version` ); - expect(routes[1].root.path).toEqual( + expect(routes[1].root!.path).toEqual( `/globalPrefix/v${ CONTROLLER_MULTIPLE_VERSIONS[1] as string }/modulePath/with-multiple-version` @@ -1437,7 +1437,7 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.path).toEqual( + expect(routes[0].root!.path).toEqual( `/globalPrefix/v${DEFAULT_VERSION}/modulePath/with-multiple-version` ); }); @@ -1489,12 +1489,12 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.path).toEqual( + expect(routes[0].root!.path).toEqual( `/globalPrefix/v${ CONTROLLER_MULTIPLE_VERSIONS[0] as string }/modulePath/with-multiple-version` ); - expect(routes[1].root.path).toEqual( + expect(routes[1].root!.path).toEqual( `/globalPrefix/modulePath/with-multiple-version` ); }); @@ -1513,8 +1513,8 @@ describe('SwaggerExplorer', () => { (route) => route.root?.method === 'post' ); - expect(postRoutes[0].root.requestBody).toBeDefined(); - expect(postRoutes[1].root.requestBody).toBeDefined(); + expect(postRoutes[0].root!.requestBody).toBeDefined(); + expect(postRoutes[1].root!.requestBody).toBeDefined(); }); }); }); @@ -1540,13 +1540,22 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes.length).toEqual(7); + expect(routes.length).toEqual(8); expect( - ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every( - (method) => routes.find((route) => route.root.method === method) + [ + 'get', + 'post', + 'put', + 'delete', + 'patch', + 'options', + 'head', + 'search' + ].every((method) => + routes.find((route) => route.root!.method === method) ) ).toBe(true); - expect(routes.find((route) => route.root.method === 'all')).toBe( + expect(routes.find((route) => route.root!.method === 'all')).toBe( undefined ); // check if all routes are equal except for method @@ -1561,7 +1570,7 @@ describe('SwaggerExplorer', () => { }); }); - describe('when global paramters are defined', () => { + describe('when global parameters are defined', () => { class Foo {} @Controller('') @@ -1572,7 +1581,7 @@ describe('SwaggerExplorer', () => { } } - it('should properly define global paramters', () => { + it('should properly define global parameters', () => { GlobalParametersStorage.add( { name: 'x-tenant-id', @@ -1596,7 +1605,7 @@ describe('SwaggerExplorer', () => { 'globalPrefix' ); - expect(routes[0].root.parameters).toEqual([ + expect(routes[0].root!.parameters).toEqual([ { name: 'x-tenant-id', in: 'header', diff --git a/test/plugin/controller-class-visitor.spec.ts b/test/plugin/controller-class-visitor.spec.ts index 90cd7d47a..63693bbed 100644 --- a/test/plugin/controller-class-visitor.spec.ts +++ b/test/plugin/controller-class-visitor.spec.ts @@ -17,7 +17,7 @@ describe('Controller methods', () => { it('should add response based on the return value (spaces)', () => { const options: ts.CompilerOptions = { module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ESNext, + target: ts.ScriptTarget.ES2021, newLine: ts.NewLineKind.LineFeed, noEmitHelpers: true, experimentalDecorators: true @@ -43,7 +43,7 @@ describe('Controller methods', () => { it('should add response based on the return value (tabs)', () => { const options: ts.CompilerOptions = { module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ESNext, + target: ts.ScriptTarget.ES2021, newLine: ts.NewLineKind.LineFeed, noEmitHelpers: true, experimentalDecorators: true @@ -69,7 +69,7 @@ describe('Controller methods', () => { it('should add response based on the return value (without modifiers)', () => { const options: ts.CompilerOptions = { module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ESNext, + target: ts.ScriptTarget.ES2021, newLine: ts.NewLineKind.LineFeed, noEmitHelpers: true, experimentalDecorators: true diff --git a/test/plugin/fixtures/app.controller-tabs.ts b/test/plugin/fixtures/app.controller-tabs.ts index cf3887e53..598b7e829 100644 --- a/test/plugin/fixtures/app.controller-tabs.ts +++ b/test/plugin/fixtures/app.controller-tabs.ts @@ -44,10 +44,10 @@ Object.defineProperty(exports, \"__esModule\", { value: true }); exports.AppController = void 0; const openapi = require(\"@nestjs/swagger\"); const common_1 = require(\"@nestjs/common\"); -const swagger_1 = require("@nestjs/swagger"); +const swagger_1 = require(\"@nestjs/swagger\"); class Cat { } -let AppController = class AppController { +let AppController = exports.AppController = class AppController { onApplicationBootstrap() { } /** * create a Cat @@ -69,23 +69,22 @@ let AppController = class AppController { async findAll() { } }; __decorate([ - openapi.ApiOperation({ summary: "create a Cat" }), + openapi.ApiOperation({ summary: \"create a Cat\" }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"create\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "find a Cat" }), + (0, swagger_1.ApiOperation)({ summary: \"find a Cat\" }), Get(), openapi.ApiResponse({ status: 200, type: Cat }) ], AppController.prototype, \"findOne\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "find all Cats im comment", description: 'find all Cats' }), + (0, swagger_1.ApiOperation)({ summary: \"find all Cats im comment\", description: 'find all Cats' }), Get(), HttpCode(common_1.HttpStatus.NO_CONTENT), openapi.ApiResponse({ status: common_1.HttpStatus.NO_CONTENT, type: [Cat] }) ], AppController.prototype, \"findAll\", null); -AppController = __decorate([ +exports.AppController = AppController = __decorate([ (0, common_1.Controller)('cats') ], AppController); -exports.AppController = AppController; `; diff --git a/test/plugin/fixtures/app.controller-without-modifiers.ts b/test/plugin/fixtures/app.controller-without-modifiers.ts index f41e68a4b..404dd04b3 100644 --- a/test/plugin/fixtures/app.controller-without-modifiers.ts +++ b/test/plugin/fixtures/app.controller-without-modifiers.ts @@ -67,10 +67,10 @@ Object.defineProperty(exports, \"__esModule\", { value: true }); exports.AppController = void 0; const openapi = require(\"@nestjs/swagger\"); const common_1 = require(\"@nestjs/common\"); -const swagger_1 = require("@nestjs/swagger"); +const swagger_1 = require(\"@nestjs/swagger\"); class Cat { } -let AppController = class AppController { +let AppController = exports.AppController = class AppController { onApplicationBootstrap() { } /** * create a Cat @@ -109,27 +109,27 @@ let AppController = class AppController { noComment() { } }; __decorate([ - openapi.ApiOperation({ summary: "create a Cat" }), + openapi.ApiOperation({ summary: \"create a Cat\" }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"create\", null); __decorate([ - openapi.ApiOperation({ summary: "create a test Cat", deprecated: true }), + openapi.ApiOperation({ summary: \"create a test Cat\", deprecated: true }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"testCreate\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "create a test Cat, not actually deprecated", deprecated: false }), + (0, swagger_1.ApiOperation)({ summary: \"create a test Cat, not actually deprecated\", deprecated: false }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"testCreate2\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "find a Cat" }), + (0, swagger_1.ApiOperation)({ summary: \"find a Cat\" }), Get(), openapi.ApiResponse({ status: 200, type: Cat }) ], AppController.prototype, \"findOne\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "find all Cats im comment", description: 'find all Cats' }), + (0, swagger_1.ApiOperation)({ summary: \"find all Cats im comment\", description: 'find all Cats' }), Get(), HttpCode(common_1.HttpStatus.NO_CONTENT), openapi.ApiResponse({ status: common_1.HttpStatus.NO_CONTENT, type: [Cat] }) @@ -139,8 +139,7 @@ __decorate([ HttpCode(common_1.HttpStatus.NO_CONTENT), openapi.ApiResponse({ status: common_1.HttpStatus.NO_CONTENT }) ], AppController.prototype, \"noComment\", null); -AppController = __decorate([ +exports.AppController = AppController = __decorate([ (0, common_1.Controller)('cats') ], AppController); -exports.AppController = AppController; `; diff --git a/test/plugin/fixtures/app.controller.ts b/test/plugin/fixtures/app.controller.ts index 5069697ca..385a21c5c 100644 --- a/test/plugin/fixtures/app.controller.ts +++ b/test/plugin/fixtures/app.controller.ts @@ -63,10 +63,10 @@ Object.defineProperty(exports, \"__esModule\", { value: true }); exports.AppController = void 0; const openapi = require(\"@nestjs/swagger\"); const common_1 = require(\"@nestjs/common\"); -const swagger_1 = require("@nestjs/swagger"); +const swagger_1 = require(\"@nestjs/swagger\"); class Cat { } -let AppController = class AppController { +let AppController = exports.AppController = class AppController { onApplicationBootstrap() { } /** * create a Cat @@ -104,33 +104,32 @@ let AppController = class AppController { async findAll() { } }; __decorate([ - openapi.ApiOperation({ summary: "create a Cat" }), + openapi.ApiOperation({ summary: \"create a Cat\" }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"create\", null); __decorate([ - openapi.ApiOperation({ summary: "create a test Cat", deprecated: true }), + openapi.ApiOperation({ summary: \"create a test Cat\", deprecated: true }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"testCreate\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "create a test Cat, not actually deprecated", deprecated: false }), + (0, swagger_1.ApiOperation)({ summary: \"create a test Cat, not actually deprecated\", deprecated: false }), (0, common_1.Post)(), openapi.ApiResponse({ status: 201, type: Cat }) ], AppController.prototype, \"testCreate2\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "find a Cat" }), + (0, swagger_1.ApiOperation)({ summary: \"find a Cat\" }), Get(), openapi.ApiResponse({ status: 200, type: Cat }) ], AppController.prototype, \"findOne\", null); __decorate([ - (0, swagger_1.ApiOperation)({ summary: "find all Cats im comment", description: 'find all Cats' }), + (0, swagger_1.ApiOperation)({ summary: \"find all Cats im comment\", description: 'find all Cats' }), Get(), HttpCode(common_1.HttpStatus.NO_CONTENT), openapi.ApiResponse({ status: common_1.HttpStatus.NO_CONTENT, type: [Cat] }) ], AppController.prototype, \"findAll\", null); -AppController = __decorate([ +exports.AppController = AppController = __decorate([ (0, common_1.Controller)('cats') ], AppController); -exports.AppController = AppController; `; diff --git a/test/plugin/fixtures/project/app.controller.ts b/test/plugin/fixtures/project/app.controller.ts new file mode 100644 index 000000000..6ef66f594 --- /dev/null +++ b/test/plugin/fixtures/project/app.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + /** + * Says hello + * @deprecated + */ + @Get() + getHello(): string { + return 'Hello world!'; + } + + @Get(['alias1', 'alias2']) + withAliases(): string { + return 'Hello world!'; + } + + @Get('express[:]colon[:]another/:prop') + withColonExpress(): string { + return 'Hello world!'; + } + + @Get('fastify::colon::another/:prop') + withColonFastify(): string { + return 'Hello world!'; + } +} diff --git a/test/plugin/fixtures/project/app.module.ts b/test/plugin/fixtures/project/app.module.ts new file mode 100644 index 000000000..c381134c9 --- /dev/null +++ b/test/plugin/fixtures/project/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { CatsModule } from './cats/cats.module'; + +@Module({ + imports: [CatsModule], + controllers: [AppController] +}) +export class ApplicationModule {} diff --git a/test/plugin/fixtures/project/cats/cats.controller.ts b/test/plugin/fixtures/project/cats/cats.controller.ts new file mode 100644 index 000000000..2885ed086 --- /dev/null +++ b/test/plugin/fixtures/project/cats/cats.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiOperation } from '../../../../lib'; +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'; + +@Controller('cats') +export class CatsController { + constructor(private readonly catsService: CatsService) {} + + @Post() + @ApiOperation({ summary: 'Create cat' }) + async create(@Body() createCatDto: CreateCatDto): Promise { + return this.catsService.create(createCatDto); + } + + @Get(':id') + findOne(@Param('id') id: string): Cat { + return this.catsService.findOne(+id); + } + + @Get() + findAll(@Query() paginationQuery: PaginationQuery) {} + + @Post('bulk') + async createBulk(@Body() createCatDto: CreateCatDto[]): Promise { + return null; + } + + @Post('as-form-data') + @ApiOperation({ summary: 'Create cat' }) + async createAsFormData(@Body() createCatDto: CreateCatDto): Promise { + return this.catsService.create(createCatDto); + } + + @Get('with-enum/:type') + getWithEnumParam(@Param('type') type: LettersEnum) {} + + @Get('with-random-query') + getWithRandomQuery(@Query('type') type: string) {} +} diff --git a/test/plugin/fixtures/project/cats/cats.module.ts b/test/plugin/fixtures/project/cats/cats.module.ts new file mode 100644 index 000000000..0ffa85b67 --- /dev/null +++ b/test/plugin/fixtures/project/cats/cats.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CatsController } from './cats.controller'; +import { CatsService } from './cats.service'; + +@Module({ + controllers: [CatsController], + providers: [CatsService] +}) +export class CatsModule {} diff --git a/test/plugin/fixtures/project/cats/cats.service.ts b/test/plugin/fixtures/project/cats/cats.service.ts new file mode 100644 index 000000000..4505f6724 --- /dev/null +++ b/test/plugin/fixtures/project/cats/cats.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { Cat } from './classes/cat.class'; +import { CreateCatDto } from './dto/create-cat.dto'; + +@Injectable() +export class CatsService { + private readonly cats: Cat[] = []; + + create(cat: CreateCatDto): Cat { + this.cats.push(cat); + return cat; + } + + findOne(id: number): Cat { + return this.cats[id]; + } +} diff --git a/test/plugin/fixtures/project/cats/classes/cat.class.ts b/test/plugin/fixtures/project/cats/classes/cat.class.ts new file mode 100644 index 000000000..a70189ebd --- /dev/null +++ b/test/plugin/fixtures/project/cats/classes/cat.class.ts @@ -0,0 +1,28 @@ +import { LettersEnum } from '../dto/pagination-query.dto'; + +export class Cat { + name: string; + + /** + * The age of the Cat + * @example 4 + */ + age: number; + + /** + * The breed of the Cat + */ + breed: string; + + tags?: string[]; + + createdAt: Date; + + urls?: string[]; + + options?: Record[]; + + enum: LettersEnum; + + enumArr: LettersEnum; +} diff --git a/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts b/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts new file mode 100644 index 000000000..0460bbd7a --- /dev/null +++ b/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts @@ -0,0 +1,69 @@ +import { ApiExtraModels, ApiProperty } from '../../../../lib'; +import { ExtraModel } from './extra-model.dto'; +import { LettersEnum } from './pagination-query.dto'; +import { TagDto } from './tag.dto'; + +@ApiExtraModels(ExtraModel) +export class CreateCatDto { + @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[]; + + @ApiProperty({ + type: 'array', + items: { + type: 'object', + properties: { + isReadonly: { + type: 'string' + } + } + } + }) + readonly options?: Record[]; + + @ApiProperty({ + enum: LettersEnum, + enumName: 'LettersEnum' + }) + readonly enum: LettersEnum; + + @ApiProperty({ + enum: LettersEnum, + enumName: 'LettersEnum', + isArray: true + }) + readonly enumArr: LettersEnum; + + readonly enumArr2: LettersEnum[]; + + @ApiProperty({ description: 'tag', required: false }) + readonly tag: TagDto; + + readonly multipleTags: TagDto[]; + + nested: { + first: string; + second: number; + }; +} diff --git a/test/plugin/fixtures/project/cats/dto/extra-model.dto.ts b/test/plugin/fixtures/project/cats/dto/extra-model.dto.ts new file mode 100644 index 000000000..63edfa88d --- /dev/null +++ b/test/plugin/fixtures/project/cats/dto/extra-model.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '../../../../lib'; + +export class ExtraModel { + @ApiProperty() + readonly one: string; + + @ApiProperty() + readonly two: number; +} diff --git a/test/plugin/fixtures/project/cats/dto/pagination-query.dto.ts b/test/plugin/fixtures/project/cats/dto/pagination-query.dto.ts new file mode 100644 index 000000000..1572e8718 --- /dev/null +++ b/test/plugin/fixtures/project/cats/dto/pagination-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from '../../../../lib'; + +export enum LettersEnum { + A = 'A', + B = 'B', + C = 'C' +} + +export class PaginationQuery { + @ApiProperty({ + minimum: 0, + maximum: 10000, + title: 'Page', + exclusiveMaximum: true, + exclusiveMinimum: true, + format: 'int32', + default: 0 + }) + page: number; + + @ApiProperty({ + name: '_sortBy', + nullable: true + }) + sortBy: string[]; + + @ApiProperty() + limit: number; + + @ApiProperty({ + enum: LettersEnum, + enumName: 'LettersEnum' + }) + enum: LettersEnum; + + @ApiProperty({ + enum: LettersEnum, + enumName: 'LettersEnum', + isArray: true + }) + enumArr: LettersEnum[]; + + @ApiProperty({ + enum: LettersEnum, + enumName: 'Letter', + isArray: true, + }) + letters: LettersEnum[]; + + @ApiProperty() + beforeDate: Date; + + @ApiProperty({ + type: 'object', + additionalProperties: true + }) + filter: Record; + + static _OPENAPI_METADATA_FACTORY() { + return { + sortBy: { type: () => [String] } + }; + } +} diff --git a/test/plugin/fixtures/project/cats/dto/tag.dto.ts b/test/plugin/fixtures/project/cats/dto/tag.dto.ts new file mode 100644 index 000000000..3862bb962 --- /dev/null +++ b/test/plugin/fixtures/project/cats/dto/tag.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '../../../../lib'; + +export class TagDto { + @ApiProperty({ description: 'name' }) + name: string; +} diff --git a/test/plugin/fixtures/project/tsconfig.json b/test/plugin/fixtures/project/tsconfig.json new file mode 100644 index 000000000..2f6b08ab5 --- /dev/null +++ b/test/plugin/fixtures/project/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true + }, + "include": ["./**/*"] +} diff --git a/test/plugin/fixtures/serialized-meta.fixture.ts b/test/plugin/fixtures/serialized-meta.fixture.ts new file mode 100644 index 000000000..1860a927f --- /dev/null +++ b/test/plugin/fixtures/serialized-meta.fixture.ts @@ -0,0 +1,157 @@ +// @ts-nocheck +export default async () => { + const t = { + ['./cats/dto/pagination-query.dto']: await import( + './cats/dto/pagination-query.dto' + ).then((f) => f.LettersEnum), + ['./cats/dto/tag.dto']: await import('./cats/dto/tag.dto').then( + (f) => f.TagDto + ) + }; + return { + '@nestjs/swagger': { + models: [ + [ + import('./cats/dto/pagination-query.dto'), + { + PaginationQuery: { + page: { required: true, type: () => Number }, + sortBy: { required: true, type: () => [String] }, + limit: { required: true, type: () => Number }, + enum: { + required: true, + enum: t['./cats/dto/pagination-query.dto'] + }, + enumArr: { + required: true, + enum: t['./cats/dto/pagination-query.dto'], + isArray: true + }, + letters: { + required: true, + enum: t['./cats/dto/pagination-query.dto'], + isArray: true + }, + beforeDate: { required: true, type: () => Date }, + filter: { required: true, type: () => Object } + } + } + ], + [ + import('./cats/classes/cat.class'), + { + Cat: { + name: { required: true, type: () => String }, + age: { + required: true, + type: () => Number, + description: 'The age of the Cat', + example: 4 + }, + breed: { + required: true, + type: () => String, + description: 'The breed of the Cat' + }, + tags: { required: false, type: () => [String] }, + createdAt: { required: true, type: () => Date }, + urls: { required: false, type: () => [String] }, + options: { required: false, type: () => [Object] }, + enum: { + required: true, + enum: t['./cats/dto/pagination-query.dto'] + }, + enumArr: { + required: true, + enum: t['./cats/dto/pagination-query.dto'] + } + } + } + ], + [ + import('./cats/dto/extra-model.dto'), + { + ExtraModel: { + one: { required: true, type: () => String }, + two: { required: true, type: () => Number } + } + } + ], + [ + import('./cats/dto/tag.dto'), + { TagDto: { name: { required: true, type: () => String } } } + ], + [ + import('./cats/dto/create-cat.dto'), + { + CreateCatDto: { + name: { required: true, type: () => String }, + age: { required: true, type: () => Number }, + breed: { required: true, type: () => String }, + tags: { required: false, type: () => [String] }, + createdAt: { required: true, type: () => Date }, + urls: { required: false, type: () => [String] }, + options: { required: false, type: () => [Object] }, + enum: { + required: true, + enum: t['./cats/dto/pagination-query.dto'] + }, + enumArr: { + required: true, + enum: t['./cats/dto/pagination-query.dto'] + }, + enumArr2: { + required: true, + enum: t['./cats/dto/pagination-query.dto'], + isArray: true + }, + tag: { required: true, type: () => t['./cats/dto/tag.dto'] }, + multipleTags: { + required: true, + type: () => [t['./cats/dto/tag.dto']] + }, + nested: { + required: true, + type: () => ({ + first: { required: true, type: () => String }, + second: { required: true, type: () => Number } + }) + } + } + } + ] + ], + controllers: [ + [ + import('./app.controller'), + { + AppController: { + getHello: { + description: 'Says hello', + deprecated: true, + type: String + }, + withAliases: { type: String }, + withColonExpress: { type: String }, + withColonFastify: { type: String } + } + } + ], + [ + import('./cats/cats.controller'), + { + CatsController: { + create: { type: t['./cats/classes/cat.class'] }, + findOne: { type: t['./cats/classes/cat.class'] }, + findAll: {}, + createBulk: { type: t['./cats/classes/cat.class'] }, + createAsFormData: { type: t['./cats/classes/cat.class'] }, + getWithEnumParam: {}, + getWithRandomQuery: {} + } + } + ] + ] + } + }; +}; diff --git a/test/plugin/helpers/metadata-printer.ts b/test/plugin/helpers/metadata-printer.ts new file mode 100644 index 000000000..bc9a886b3 --- /dev/null +++ b/test/plugin/helpers/metadata-printer.ts @@ -0,0 +1,123 @@ +import * as prettier from 'prettier'; +import * as ts from 'typescript'; + +export class PluginMetadataPrinter { + print( + metadata: Record>>, + typeImports: Record + ) { + const objectLiteralExpr = ts.factory.createObjectLiteralExpression( + Object.keys(metadata).map((key) => + this.recursivelyCreatePropertyAssignment( + key, + metadata[key] as unknown as Array<[ts.CallExpression, any]> + ) + ) + ); + const exportAssignment = ts.factory.createExportAssignment( + undefined, + undefined, + ts.factory.createArrowFunction( + [ts.factory.createToken(ts.SyntaxKind.AsyncKeyword)], + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock( + [ + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier('t'), + undefined, + undefined, + ts.factory.createObjectLiteralExpression( + Object.keys(typeImports).map((ti) => + this.createPropertyAssignment(ti, typeImports[ti]) + ), + true + ) + ) + ], + ts.NodeFlags.Const | + ts.NodeFlags.AwaitContext | + ts.NodeFlags.ContextFlags | + ts.NodeFlags.TypeExcludesFlags + ) + ), + ts.factory.createReturnStatement(objectLiteralExpr) + ], + true + ) + ) + ); + + const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed + }); + const resultFile = ts.createSourceFile( + 'file.ts', + '', + ts.ScriptTarget.Latest, + /*setParentNodes*/ false, + ts.ScriptKind.TS + ); + const output = printer.printNode( + ts.EmitHint.Unspecified, + exportAssignment, + resultFile + ); + return ( + `// @ts-nocheck\n` + + prettier.format(output, { + parser: 'typescript', + singleQuote: true, + trailingComma: 'none' + }) + ); + } + + private createPropertyAssignment(identifier: string, target: string) { + return ts.factory.createPropertyAssignment( + ts.factory.createComputedPropertyName( + ts.factory.createStringLiteral(identifier) + ), + ts.factory.createIdentifier(target) + ); + } + + private recursivelyCreatePropertyAssignment( + identifier: string, + meta: any | Array<[ts.CallExpression, any]> + ): ts.PropertyAssignment { + if (Array.isArray(meta)) { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(identifier), + ts.factory.createArrayLiteralExpression( + meta.map(([importExpr, meta]) => + ts.factory.createArrayLiteralExpression([ + importExpr, + ts.factory.createObjectLiteralExpression( + Object.keys(meta).map((key) => + this.recursivelyCreatePropertyAssignment(key, meta[key]) + ) + ) + ]) + ) + ) + ); + } + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(identifier), + ts.isObjectLiteralExpression(meta as unknown as ts.Node) + ? (meta as ts.ObjectLiteralExpression) + : ts.factory.createObjectLiteralExpression( + Object.keys(meta).map((key) => + this.recursivelyCreatePropertyAssignment(key, meta[key]) + ) + ) + ); + } +} diff --git a/test/plugin/readonly-visitor.spec.ts b/test/plugin/readonly-visitor.spec.ts new file mode 100644 index 000000000..3c670836b --- /dev/null +++ b/test/plugin/readonly-visitor.spec.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as ts from 'typescript'; +import { ReadonlyVisitor } from '../../lib/plugin/visitors/readonly.visitor'; +import { PluginMetadataPrinter } from './helpers/metadata-printer'; + +function createTsProgram(tsconfigPath: string) { + const parsedCmd = ts.getParsedCommandLineOfConfigFile( + tsconfigPath, + undefined, + ts.sys as unknown as ts.ParseConfigFileHost + ); + const { options, fileNames: rootNames, projectReferences } = parsedCmd!; + const program = ts.createProgram({ options, rootNames, projectReferences }); + return program; +} + +describe('Readonly visitor', () => { + const visitor = new ReadonlyVisitor({ + pathToSource: join(__dirname, 'fixtures', 'project'), + introspectComments: true, + dtoFileNameSuffix: ['.dto.ts', '.model.ts', '.class.ts'] + }); + const metadataPrinter = new PluginMetadataPrinter(); + + it('should generate a serialized metadata', () => { + const tsconfigPath = join( + __dirname, + 'fixtures', + 'project', + 'tsconfig.json' + ); + const program = createTsProgram(tsconfigPath); + + for (const sourceFile of program.getSourceFiles()) { + if (!sourceFile.isDeclarationFile) { + visitor.visit(program, sourceFile); + } + } + + const result = metadataPrinter.print( + { + [visitor.key]: visitor.collect() + }, + visitor.typeImports + ); + + const expectedOutput = readFileSync( + join(__dirname, 'fixtures', 'serialized-meta.fixture.ts'), + 'utf-8' + ); + + expect(result).toEqual(expectedOutput); + }); +});