From 384f598cb11acd8a41e4063fd7227bd53a68493e Mon Sep 17 00:00:00 2001 From: CatsMiaow Date: Tue, 18 Mar 2025 19:13:36 +0900 Subject: [PATCH 1/2] feat: make generated require() ESM compatible --- lib/plugin/merge-options.ts | 2 + lib/plugin/utils/plugin-utils.ts | 23 ++ .../visitors/controller-class.visitor.ts | 10 +- lib/plugin/visitors/model-class.visitor.ts | 10 +- lib/plugin/visitors/readonly.visitor.ts | 4 +- test/plugin/fixtures/create-option.ts | 2 + .../plugin/fixtures/parameter-property.dto.ts | 23 +- .../fixtures/serialized-meta-esm.fixture.ts | 215 ++++++++++++++++++ test/plugin/model-class-visitor.spec.ts | 21 +- test/plugin/readonly-visitor.spec.ts | 40 ++++ 10 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 test/plugin/fixtures/serialized-meta-esm.fixture.ts diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts index d3b6c5b67..7ee569065 100644 --- a/lib/plugin/merge-options.ts +++ b/lib/plugin/merge-options.ts @@ -9,6 +9,7 @@ export interface PluginOptions { dtoKeyOfComment?: string; controllerKeyOfComment?: string; introspectComments?: boolean; + esmCompatible?: boolean; readonly?: boolean; pathToSource?: string; debug?: boolean; @@ -27,6 +28,7 @@ const defaultOptions: PluginOptions = { dtoKeyOfComment: 'description', controllerKeyOfComment: 'summary', introspectComments: false, + esmCompatible: false, readonly: false, debug: false }; diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts index 57c523187..3e74c667f 100644 --- a/lib/plugin/utils/plugin-utils.ts +++ b/lib/plugin/utils/plugin-utils.ts @@ -140,6 +140,16 @@ export function hasPropertyKey( .some((item) => item.name.getText() === key); } +export function getOutputExtension(fileName: string): string { + if (fileName.endsWith('.mts')) { + return '.mjs'; + } else if (fileName.endsWith('.cts')) { + return '.cjs'; + } else { + return '.js'; + } +} + export function replaceImportPath( typeReference: string, fileName: string, @@ -148,6 +158,14 @@ export function replaceImportPath( if (!typeReference.includes('import')) { return { typeReference, importPath: null }; } + + if (options.esmCompatible) { + typeReference = typeReference.replace( + ', { with: { "resolution-mode": "import" } }', + '' + ); + } + let importPath = /\(\"([^)]).+(\")/.exec(typeReference)[0]; if (!importPath) { return { typeReference: undefined, importPath: null }; @@ -193,6 +211,10 @@ export function replaceImportPath( if (indexPos >= 0) { relativePath = relativePath.slice(0, indexPos); } + } else if (options.esmCompatible) { + // Add appropriate extension for non-node_modules imports + const extension = getOutputExtension(fileName); + relativePath += extension; } typeReference = typeReference.replace(importPath, relativePath); @@ -206,6 +228,7 @@ export function replaceImportPath( importPath: relativePath }; } + return { typeReference: typeReference.replace('import', 'require'), importPath: relativePath diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts index 91528bf34..97b739d72 100644 --- a/lib/plugin/visitors/controller-class.visitor.ts +++ b/lib/plugin/visitors/controller-class.visitor.ts @@ -15,6 +15,7 @@ import { import { convertPath, getDecoratorOrUndefinedByNames, + getOutputExtension, getTypeReferenceAsString, hasPropertyKey } from '../utils/plugin-utils'; @@ -34,13 +35,14 @@ export class ControllerClassVisitor extends AbstractFileVisitor { return this._typeImports; } - get collectedMetadata(): Array< - [ts.CallExpression, Record] - > { + collectedMetadata( + options: PluginOptions + ): Array<[ts.CallExpression, Record]> { const metadataWithImports = []; Object.keys(this._collectedMetadata).forEach((filePath) => { const metadata = this._collectedMetadata[filePath]; - const path = filePath.replace(/\.[jt]s$/, ''); + const fileExt = options.esmCompatible ? getOutputExtension(filePath) : ''; + const path = filePath.replace(/\.[jt]s$/, fileExt); const importExpr = ts.factory.createCallExpression( ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression, undefined, diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts index 04c248133..6c5ad6ba8 100644 --- a/lib/plugin/visitors/model-class.visitor.ts +++ b/lib/plugin/visitors/model-class.visitor.ts @@ -25,6 +25,7 @@ import { convertPath, extractTypeArgumentIfArray, getDecoratorOrUndefinedByNames, + getOutputExtension, getTypeReferenceAsString, hasPropertyKey, isAutoGeneratedEnumUnion, @@ -43,13 +44,14 @@ export class ModelClassVisitor extends AbstractFileVisitor { return this._typeImports; } - get collectedMetadata(): Array< - [ts.CallExpression, Record] - > { + collectedMetadata( + options: PluginOptions + ): Array<[ts.CallExpression, Record]> { const metadataWithImports = []; Object.keys(this._collectedMetadata).forEach((filePath) => { const metadata = this._collectedMetadata[filePath]; - const path = filePath.replace(/\.[jt]s$/, ''); + const fileExt = options.esmCompatible ? getOutputExtension(filePath) : ''; + const path = filePath.replace(/\.[jt]s$/, fileExt); const importExpr = ts.factory.createCallExpression( ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression, undefined, diff --git a/lib/plugin/visitors/readonly.visitor.ts b/lib/plugin/visitors/readonly.visitor.ts index 867b0cabc..e66a397db 100644 --- a/lib/plugin/visitors/readonly.visitor.ts +++ b/lib/plugin/visitors/readonly.visitor.ts @@ -50,8 +50,8 @@ export class ReadonlyVisitor { collect() { return { - models: this.modelClassVisitor.collectedMetadata, - controllers: this.controllerClassVisitor.collectedMetadata + models: this.modelClassVisitor.collectedMetadata(this.options), + controllers: this.controllerClassVisitor.collectedMetadata(this.options) }; } } diff --git a/test/plugin/fixtures/create-option.ts b/test/plugin/fixtures/create-option.ts index 9d3662c40..ea68157fb 100644 --- a/test/plugin/fixtures/create-option.ts +++ b/test/plugin/fixtures/create-option.ts @@ -16,6 +16,7 @@ export const mergedCliPluginMultiOption = { dtoKeyOfComment: 'description', controllerKeyOfComment: 'summary', introspectComments: true, + esmCompatible: false, readonly: false, debug: false }; @@ -28,6 +29,7 @@ export const mergedCliPluginSingleOption = { dtoKeyOfComment: 'description', controllerKeyOfComment: 'summary', introspectComments: true, + esmCompatible: false, readonly: false, debug: false }; diff --git a/test/plugin/fixtures/parameter-property.dto.ts b/test/plugin/fixtures/parameter-property.dto.ts index 3d8ef3f54..02d1b53ab 100644 --- a/test/plugin/fixtures/parameter-property.dto.ts +++ b/test/plugin/fixtures/parameter-property.dto.ts @@ -1,11 +1,13 @@ +import { getOutputExtension } from '../../../lib/plugin/utils/plugin-utils'; + export const parameterPropertyDtoText = ` export class ParameterPropertyDto { constructor( - readonly readonlyValue?: string, - private privateValue: string | null, - public publicValue: ItemDto[], + readonly readonlyValue?: string, + private privateValue: string | null, + public publicValue: ItemDto[], regularParameter: string - protected protectedValue: string = '1234', + protected protectedValue: string = '1234', ) {} } @@ -20,7 +22,13 @@ export class ItemDto { } `; -export const parameterPropertyDtoTextTranspiled = `import * as openapi from "@nestjs/swagger"; +export const parameterPropertyDtoTextTranspiled = (esmCompatible?: boolean) => { + let fileName = 'parameter-property.dto'; + if (esmCompatible) { + fileName += getOutputExtension(fileName); + } + + return `import * as openapi from "@nestjs/swagger"; export class ParameterPropertyDto { constructor(readonlyValue, privateValue, publicValue, regularParameter, protectedValue = '1234') { this.readonlyValue = readonlyValue; @@ -29,7 +37,7 @@ export class ParameterPropertyDto { this.protectedValue = protectedValue; } static _OPENAPI_METADATA_FACTORY() { - return { readonlyValue: { required: false, type: () => String }, privateValue: { required: true, type: () => String, nullable: true }, publicValue: { required: true, type: () => [require("./parameter-property.dto").ItemDto] }, protectedValue: { required: true, type: () => String, default: "1234" } }; + return { readonlyValue: { required: false, type: () => String }, privateValue: { required: true, type: () => String, nullable: true }, publicValue: { required: true, type: () => [require("./${fileName}").ItemDto] }, protectedValue: { required: true, type: () => String, default: "1234" } }; } } export var LettersEnum; @@ -43,7 +51,8 @@ export class ItemDto { this.enumValue = enumValue; } static _OPENAPI_METADATA_FACTORY() { - return { enumValue: { required: true, enum: require("./parameter-property.dto").LettersEnum } }; + return { enumValue: { required: true, enum: require("./${fileName}").LettersEnum } }; } } `; +}; diff --git a/test/plugin/fixtures/serialized-meta-esm.fixture.ts b/test/plugin/fixtures/serialized-meta-esm.fixture.ts new file mode 100644 index 000000000..83e5260a9 --- /dev/null +++ b/test/plugin/fixtures/serialized-meta-esm.fixture.ts @@ -0,0 +1,215 @@ +// @ts-nocheck +export default async () => { + const t = { + ['./cats/dto/pagination-query.dto.js']: await import( + './cats/dto/pagination-query.dto.js' + ), + ['./cats/dto/create-cat.dto.js']: await import( + './cats/dto/create-cat.dto.js' + ), + ['./cats/dto/tag.dto.js']: await import('./cats/dto/tag.dto.js'), + ['./cats/classes/cat.class.js']: await import('./cats/classes/cat.class.js') + }; + return { + '@nestjs/swagger': { + models: [ + [ + import('./cats/dto/pagination-query.dto.js'), + { + PaginationQuery: { + page: { required: true, type: () => Number }, + sortBy: { required: true, type: () => [String] }, + limit: { required: true, type: () => Number }, + constrainedLimit: { required: false, type: () => Number }, + enum: { + required: true, + enum: t['./cats/dto/pagination-query.dto.js'].LettersEnum + }, + enumArr: { + required: true, + enum: t['./cats/dto/pagination-query.dto.js'].LettersEnum, + isArray: true + }, + letters: { + required: true, + enum: t['./cats/dto/pagination-query.dto.js'].LettersEnum, + isArray: true + }, + beforeDate: { required: true, type: () => Date }, + filter: { required: true, type: () => Object } + } + } + ], + [ + import('./cats/classes/cat.class.js'), + { + 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.js'].LettersEnum + }, + enumArr: { + required: true, + enum: t['./cats/dto/pagination-query.dto.js'].LettersEnum + }, + uppercaseString: { required: true, type: () => String }, + lowercaseString: { required: true, type: () => String }, + capitalizeString: { required: true, type: () => String }, + uncapitalizeString: { required: true, type: () => String } + } + } + ], + [ + import('./cats/dto/extra-model.dto.js'), + { + ExtraModel: { + one: { required: true, type: () => String }, + two: { required: true, type: () => Number } + } + } + ], + [ + import('./cats/dto/tag.dto.js'), + { TagDto: { name: { required: true, type: () => String } } } + ], + [ + import('./cats/dto/create-cat.dto.js'), + { + CreateCatDto: { + isIn: { required: true, type: () => String }, + pattern: { + required: true, + type: () => String, + pattern: '/^[+]?abc$/' + }, + positive: { + required: true, + type: () => Number, + default: 5, + minimum: 1 + }, + negative: { + required: true, + type: () => Number, + default: -1, + maximum: -1 + }, + lengthMin: { + required: true, + type: () => String, + nullable: true, + default: null, + minLength: 2 + }, + lengthMinMax: { + required: true, + type: () => String, + minLength: 3, + maxLength: 5 + }, + date: { required: true, type: () => Object, default: new Date() }, + active: { required: true, type: () => Boolean, default: false }, + name: { required: true, type: () => String }, + age: { + required: true, + type: () => Number, + default: 14, + minimum: 1 + }, + breed: { required: true, type: () => String, default: 'Persian' }, + 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.js'].LettersEnum + }, + state: { + required: false, + description: 'Available language in the application', + example: 'FR', + enum: t['./cats/dto/create-cat.dto.js'].CategoryState + }, + enumArr: { + required: true, + enum: t['./cats/dto/pagination-query.dto.js'].LettersEnum + }, + enumArr2: { + required: true, + enum: t['./cats/dto/pagination-query.dto.js'].LettersEnum, + isArray: true + }, + tag: { + required: true, + type: () => t['./cats/dto/tag.dto.js'].TagDto + }, + multipleTags: { + required: true, + type: () => [t['./cats/dto/tag.dto.js'].TagDto] + }, + nested: { + required: true, + type: () => ({ + first: { required: true, type: () => String }, + second: { required: true, type: () => Number } + }) + }, + logger: { required: true, type: () => Object } + } + } + ] + ], + controllers: [ + [ + import('./app.controller.js'), + { + AppController: { + getHello: { + summary: 'Says hello', + deprecated: true, + type: String + }, + withAliases: { type: String }, + withColonExpress: { type: String }, + withColonFastify: { + summary: 'Returns information about the application', + type: String + } + } + } + ], + [ + import('./cats/cats.controller.js'), + { + CatsController: { + create: { type: t['./cats/classes/cat.class.js'].Cat }, + findOne: { type: t['./cats/classes/cat.class.js'].Cat }, + findAll: {}, + createBulk: { type: t['./cats/classes/cat.class.js'].Cat }, + createAsFormData: { type: t['./cats/classes/cat.class.js'].Cat }, + getWithEnumParam: {}, + getWithRandomQuery: {} + } + } + ] + ] + } + }; +}; diff --git a/test/plugin/model-class-visitor.spec.ts b/test/plugin/model-class-visitor.spec.ts index 36dd2a716..6274333ca 100644 --- a/test/plugin/model-class-visitor.spec.ts +++ b/test/plugin/model-class-visitor.spec.ts @@ -287,7 +287,26 @@ describe('API model properties', () => { ] } }); - expect(result.outputText).toEqual(parameterPropertyDtoTextTranspiled); + expect(result.outputText).toEqual(parameterPropertyDtoTextTranspiled()); + + const esmResult = ts.transpileModule(parameterPropertyDtoText, { + compilerOptions: options, + fileName: filename, + transformers: { + before: [ + before( + { + introspectComments: true, + esmCompatible: true, + classValidatorShim: true, + parameterProperties: true + }, + fakeProgram + ) + ] + } + }); + expect(esmResult.outputText).toEqual(parameterPropertyDtoTextTranspiled(true)); }); it('should ignore Exclude decorator', () => { diff --git a/test/plugin/readonly-visitor.spec.ts b/test/plugin/readonly-visitor.spec.ts index 1331989c7..f922ba388 100644 --- a/test/plugin/readonly-visitor.spec.ts +++ b/test/plugin/readonly-visitor.spec.ts @@ -23,6 +23,14 @@ describe('Readonly visitor', () => { classValidatorShim: true, debug: true }); + const esmVisitor = new ReadonlyVisitor({ + pathToSource: join(__dirname, 'fixtures', 'project'), + introspectComments: true, + esmCompatible: true, + dtoFileNameSuffix: ['.dto.ts', '.model.ts', '.class.ts'], + classValidatorShim: true, + debug: true + }); const metadataPrinter = new PluginMetadataPrinter(); it('should generate a serialized metadata', () => { @@ -63,4 +71,36 @@ describe('Readonly visitor', () => { expect(result).toEqual(expectedOutput); }); + + it('should generate a serialized metadata esm', () => { + const tsconfigPath = join( + __dirname, + 'fixtures', + 'project', + 'tsconfig.json' + ); + const program = createTsProgram(tsconfigPath); + + for (const sourceFile of program.getSourceFiles()) { + if (!sourceFile.isDeclarationFile) { + esmVisitor.visit(program, sourceFile); + } + } + + const result = metadataPrinter.print( + { + [esmVisitor.key]: esmVisitor.collect() + }, + esmVisitor.typeImports + ); + + const expectedOutput = readFileSync( + join(__dirname, 'fixtures', 'serialized-meta-esm.fixture.ts'), + 'utf-8' + ) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + + expect(result).toEqual(expectedOutput); + }); }); From 050ff341f7da7fc982e95e93e679f5cef91fb221 Mon Sep 17 00:00:00 2001 From: CatsMiaow Date: Tue, 25 Mar 2025 16:55:36 +0900 Subject: [PATCH 2/2] build: add prepare/index for repo install --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index a2b44919b..baed428aa 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "author": "Kamil Mysliwiec", "license": "MIT", "repository": "https://github.com/nestjs/swagger", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.build.json", "format": "prettier \"lib/**/*.ts\" --write", @@ -13,6 +15,7 @@ "publish:next": "npm publish --access public --tag next", "prepublish:npm": "npm run build", "publish:npm": "npm publish --access public", + "prepare": "npm run build", "test": "jest", "test:dev": "jest --watch", "test:e2e": "jest --config e2e/jest-e2e.json",