From 20c57363537a3c7c28ca9e011831f667168fd13f Mon Sep 17 00:00:00 2001 From: Moritz Kalwa <62842654+moritzkalwa@users.noreply.github.com> Date: Sat, 17 Feb 2024 18:36:52 +0100 Subject: [PATCH] feat: export and import interfaces as types when using named imports and ESM Module Syntax (#1800) --- examples/README.md | 1 + .../typescript-interfaces-as-types/README.md | 17 +++++++ .../__snapshots__/index.spec.ts.snap | 23 +++++++++ .../index.spec.ts | 16 +++++++ .../typescript-interfaces-as-types/index.ts | 34 ++++++++++++++ .../package-lock.json | 10 ++++ .../package.json | 10 ++++ .../typescript/TypeScriptDependencyManager.ts | 47 +++++++++++++++---- .../typescript/TypeScriptGenerator.ts | 9 ++-- src/helpers/DependencyHelpers.ts | 15 ++++-- .../typescript/TypeScriptGenerator.spec.ts | 13 +++++ .../TypeScriptGenerator.spec.ts.snap | 25 ++++++++++ 12 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 examples/typescript-interfaces-as-types/README.md create mode 100644 examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap create mode 100644 examples/typescript-interfaces-as-types/index.spec.ts create mode 100644 examples/typescript-interfaces-as-types/index.ts create mode 100644 examples/typescript-interfaces-as-types/package-lock.json create mode 100644 examples/typescript-interfaces-as-types/package.json diff --git a/examples/README.md b/examples/README.md index 332852ece6..b46db93319 100644 --- a/examples/README.md +++ b/examples/README.md @@ -135,6 +135,7 @@ These are all specific examples only relevant to the TypeScript generator: - [typescript-generate-example](./typescript-generate-example) - A basic example of how to use Modelina and output a TypeScript class with an example function. - [typescript-generate-marshalling](./typescript-generate-marshalling) - A basic example of how to use the un/marshalling functionality of the typescript class. - [typescript-generate-comments](./typescript-generate-comments) - A basic example of how to generate TypeScript interfaces with comments from description and example fields. +- [typescript-interfaces-as-types](./typescript-interfaces-as-types) - A basic example that generate the models as interfaces and uses ESM module system syntax to import and export them as types. - [typescript-use-esm](./typescript-use-esm) - A basic example that generate the models to use ESM module system. - [typescript-use-cjs](./typescript-use-cjs) - A basic example that generate the models to use CJS module system. - [typescript-generate-jsonbinpack](./typescript-generate-jsonbinpack) - A basic example showing how to generate models that include [jsonbinpack](https://github.com/sourcemeta/jsonbinpack) functionality. diff --git a/examples/typescript-interfaces-as-types/README.md b/examples/typescript-interfaces-as-types/README.md new file mode 100644 index 0000000000..57c7ce7899 --- /dev/null +++ b/examples/typescript-interfaces-as-types/README.md @@ -0,0 +1,17 @@ +# TypeScript interface as types + +A basic example of how to use Modelina and output TypeScript interfaces that are exported as types using ESM Module Syntax. + +## How to run this example + +Run this example using: + +```sh +npm i && npm run start +``` + +If you are on Windows, use the `start:windows` script instead: + +```sh +npm i && npm run start:windows +``` diff --git a/examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap b/examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000000..5e99c49887 --- /dev/null +++ b/examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should be able to render typescript interfaces as types and should log expected output to console 1`] = ` +Array [ + "import type {OtherModel} from './OtherModel'; +interface Root { + email?: string; + otherModel?: OtherModel; +} +export type { Root };", +] +`; + +exports[`Should be able to render typescript interfaces as types and should log expected output to console 2`] = ` +Array [ + " +interface OtherModel { + streetName?: string; + additionalProperties?: Map; +} +export type { OtherModel };", +] +`; diff --git a/examples/typescript-interfaces-as-types/index.spec.ts b/examples/typescript-interfaces-as-types/index.spec.ts new file mode 100644 index 0000000000..d83e7f3c79 --- /dev/null +++ b/examples/typescript-interfaces-as-types/index.spec.ts @@ -0,0 +1,16 @@ +const spy = jest.spyOn(global.console, 'log').mockImplementation(() => { + return; +}); +import { generate } from './index'; + +describe('Should be able to render typescript interfaces as types', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + test('and should log expected output to console', async () => { + await generate(); + expect(spy.mock.calls.length).toEqual(2); + expect(spy.mock.calls[0]).toMatchSnapshot(); + expect(spy.mock.calls[1]).toMatchSnapshot(); + }); +}); diff --git a/examples/typescript-interfaces-as-types/index.ts b/examples/typescript-interfaces-as-types/index.ts new file mode 100644 index 0000000000..57f909d48b --- /dev/null +++ b/examples/typescript-interfaces-as-types/index.ts @@ -0,0 +1,34 @@ +import { TypeScriptGenerator } from '../../src'; + +const generator = new TypeScriptGenerator({ + modelType: 'interface', + moduleSystem: 'ESM' +}); +const jsonSchemaDraft7 = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + additionalProperties: false, + properties: { + email: { + type: 'string', + format: 'email' + }, + other_model: { + type: 'object', + $id: 'OtherModel', + properties: { street_name: { type: 'string' } } + } + } +}; + +export async function generate(): Promise { + const models = await generator.generateCompleteModels(jsonSchemaDraft7, { + exportType: 'named' + }); + for (const model of models) { + console.log(model.result); + } +} +if (require.main === module) { + generate(); +} diff --git a/examples/typescript-interfaces-as-types/package-lock.json b/examples/typescript-interfaces-as-types/package-lock.json new file mode 100644 index 0000000000..02096c0240 --- /dev/null +++ b/examples/typescript-interfaces-as-types/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "typescript-interfaces-as-types", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "hasInstallScript": true + } + } +} diff --git a/examples/typescript-interfaces-as-types/package.json b/examples/typescript-interfaces-as-types/package.json new file mode 100644 index 0000000000..80bd9b401d --- /dev/null +++ b/examples/typescript-interfaces-as-types/package.json @@ -0,0 +1,10 @@ +{ + "config" : { "example_name" : "typescript-interfaces-as-types" }, + "scripts": { + "install": "cd ../.. && npm i", + "start": "../../node_modules/.bin/ts-node --cwd ../../ ./examples/$npm_package_config_example_name/index.ts", + "start:windows": "..\\..\\node_modules\\.bin\\ts-node --cwd ..\\..\\ .\\examples\\%npm_package_config_example_name%\\index.ts", + "test": "../../node_modules/.bin/jest --config=../../jest.config.js ./examples/$npm_package_config_example_name/index.spec.ts", + "test:windows": "..\\..\\node_modules\\.bin\\jest --config=..\\..\\jest.config.js examples/%npm_package_config_example_name%/index.spec.ts" + } +} diff --git a/src/generators/typescript/TypeScriptDependencyManager.ts b/src/generators/typescript/TypeScriptDependencyManager.ts index c8a2c948a8..461ab143c6 100644 --- a/src/generators/typescript/TypeScriptDependencyManager.ts +++ b/src/generators/typescript/TypeScriptDependencyManager.ts @@ -1,7 +1,11 @@ import { AbstractDependencyManager } from '../AbstractDependencyManager'; import { renderJavaScriptDependency } from '../../helpers'; import { ConstrainedMetaModel } from '../../models'; -import { TypeScriptExportType, TypeScriptOptions } from './TypeScriptGenerator'; +import { + TypeScriptExportType, + TypeScriptModelType, + TypeScriptOptions +} from './TypeScriptGenerator'; export class TypeScriptDependencyManager extends AbstractDependencyManager { constructor( @@ -14,19 +18,32 @@ export class TypeScriptDependencyManager extends AbstractDependencyManager { /** * Simple helper function to add a dependency correct based on the module system that the user defines. */ - addTypeScriptDependency(toImport: string, fromModule: string): void { - const dependencyImport = this.renderDependency(toImport, fromModule); + addTypeScriptDependency( + toImport: string, + fromModule: string, + modelType: TypeScriptModelType + ): void { + const dependencyImport = this.renderDependency( + toImport, + fromModule, + modelType + ); this.addDependency(dependencyImport); } /** * Simple helper function to render a dependency based on the module system that the user defines. */ - renderDependency(toImport: string, fromModule: string): string { + renderDependency( + toImport: string, + fromModule: string, + modelType: TypeScriptModelType + ): string { return renderJavaScriptDependency( toImport, fromModule, - this.options.moduleSystem + this.options.moduleSystem, + modelType === 'interface' ? 'type' : 'value' ); } @@ -35,11 +52,16 @@ export class TypeScriptDependencyManager extends AbstractDependencyManager { */ renderCompleteModelDependencies( model: ConstrainedMetaModel, - exportType: TypeScriptExportType + exportType: TypeScriptExportType, + modelType: TypeScriptModelType ): string { const dependencyObject = exportType === 'named' ? `{${model.name}}` : model.name; - return this.renderDependency(dependencyObject, `./${model.name}`); + return this.renderDependency( + dependencyObject, + `./${model.name}`, + modelType + ); } /** @@ -47,16 +69,23 @@ export class TypeScriptDependencyManager extends AbstractDependencyManager { */ renderExport( model: ConstrainedMetaModel, - exportType: TypeScriptExportType + exportType: TypeScriptExportType, + modelType: TypeScriptModelType ): string { const cjsExport = exportType === 'default' ? `module.exports = ${model.name};` : `exports.${model.name} = ${model.name};`; - const esmExport = + const esmExportValue = exportType === 'default' ? `export default ${model.name};\n` : `export { ${model.name} };`; + const esmExportType = + exportType === 'default' + ? `export default ${model.name};\n` + : `export type { ${model.name} };`; + const esmExport = + modelType === 'interface' ? esmExportType : esmExportValue; return this.options.moduleSystem === 'CJS' ? cjsExport : esmExport; } } diff --git a/src/generators/typescript/TypeScriptGenerator.ts b/src/generators/typescript/TypeScriptGenerator.ts index 074bdeb367..96932f3857 100644 --- a/src/generators/typescript/TypeScriptGenerator.ts +++ b/src/generators/typescript/TypeScriptGenerator.ts @@ -39,13 +39,14 @@ import { TypeScriptDependencyManager } from './TypeScriptDependencyManager'; export type TypeScriptModuleSystemType = 'ESM' | 'CJS'; export type TypeScriptExportType = 'named' | 'default'; +export type TypeScriptModelType = 'class' | 'interface'; export interface TypeScriptOptions extends CommonGeneratorOptions< TypeScriptPreset, TypeScriptDependencyManager > { renderTypes: boolean; - modelType: 'class' | 'interface'; + modelType: TypeScriptModelType; enumType: 'enum' | 'union'; mapType: 'indexedObject' | 'map' | 'record'; typeMapping: TypeMapping; @@ -210,12 +211,14 @@ export class TypeScriptGenerator extends AbstractGenerator< const modelDependencyImports = modelDependencies.map((model) => { return dependencyManagerToUse.renderCompleteModelDependencies( model, - completeModelOptionsToUse.exportType + completeModelOptionsToUse.exportType, + optionsToUse.modelType ); }); const modelExport = dependencyManagerToUse.renderExport( args.constrainedModel, - completeModelOptionsToUse.exportType + completeModelOptionsToUse.exportType, + optionsToUse.modelType ); const modelCode = `${outputModel.result}\n${modelExport}`; diff --git a/src/helpers/DependencyHelpers.ts b/src/helpers/DependencyHelpers.ts index 472101a64a..24fb302fff 100644 --- a/src/helpers/DependencyHelpers.ts +++ b/src/helpers/DependencyHelpers.ts @@ -10,11 +10,18 @@ import { ConstrainedMetaModel } from '../models'; export function renderJavaScriptDependency( toImport: string, fromModule: string, - moduleSystem: 'CJS' | 'ESM' + moduleSystem: 'CJS' | 'ESM', + importType: 'type' | 'value' = 'value' ): string { - return moduleSystem === 'CJS' - ? `const ${toImport} = require('${fromModule}');` - : `import ${toImport} from '${fromModule}';`; + const importValueStatement = + moduleSystem === 'CJS' + ? `const ${toImport} = require('${fromModule}');` + : `import ${toImport} from '${fromModule}';`; + const importTypeStatement = + moduleSystem === 'CJS' + ? `const ${toImport} = require('${fromModule}');` + : `import type ${toImport} from '${fromModule}';`; + return importType === 'type' ? importTypeStatement : importValueStatement; } /** diff --git a/test/generators/typescript/TypeScriptGenerator.spec.ts b/test/generators/typescript/TypeScriptGenerator.spec.ts index 085501ec40..74e8a804be 100644 --- a/test/generators/typescript/TypeScriptGenerator.spec.ts +++ b/test/generators/typescript/TypeScriptGenerator.spec.ts @@ -381,6 +381,19 @@ ${content}`; expect(models[1].result).toMatchSnapshot(); }); + test('should render models and their dependencies for ESM module system with named exports as interfaces', async () => { + generator = new TypeScriptGenerator({ + moduleSystem: 'ESM', + modelType: 'interface' + }); + const models = await generator.generateCompleteModels(doc, { + exportType: 'named' + }); + expect(models).toHaveLength(2); + expect(models[0].result).toMatchSnapshot(); + expect(models[1].result).toMatchSnapshot(); + }); + describe('AsyncAPI with polymorphism', () => { const asyncapiDoc = { asyncapi: '2.4.0', diff --git a/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap b/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap index cdacd89e0c..679ad16302 100644 --- a/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap +++ b/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap @@ -948,6 +948,31 @@ class OtherModel { export { OtherModel };" `; +exports[`TypeScriptGenerator should render models and their dependencies for ESM module system with named exports as interfaces 1`] = ` +"import type {OtherModel} from './OtherModel'; +interface Address { + streetName: string; + city: string; + state: string; + houseNumber: number; + marriage?: boolean; + members?: string | number | boolean; + arrayType: (string | number | any)[]; + otherModel?: OtherModel; + additionalProperties?: Map; +} +export type { Address };" +`; + +exports[`TypeScriptGenerator should render models and their dependencies for ESM module system with named exports as interfaces 2`] = ` +" +interface OtherModel { + streetName?: string; + additionalProperties?: Map; +} +export type { OtherModel };" +`; + exports[`TypeScriptGenerator should render union \`enum\` values 1`] = ` "enum States { NUMBER_2 = 2,