From ae43427e993ea7d6b6fe6fb55cdc9fbfe32ea98a Mon Sep 17 00:00:00 2001 From: slaesh Date: Thu, 22 Oct 2026 10:28:15 +0200 Subject: [PATCH 1/2] [typescript-angular] Fix multiple imports from same module in importMappings and isolatedModules re-export - In TypeScriptAngularClientCodegen.postProcessOperationsWithModels, recompute service model imports so all types mapping to the same external module/package (e.g. via importMappings) are preserved and imported in the generated API services. - In TypeScriptAngularClientCodegen.postProcessSupportingFileData, deduplicate external models per module and re-export them via 'export type { ... }' in models.mustache (with fallback to 'export { ... }' for Angular 9 / TypeScript < 3.8) to eliminate duplicate 'export *' lines and prevent TS1205 errors under isolatedModules: true. - Add unit test testMultipleImportsFromSameModuleAndExternalModelReExport in TypeScriptAngularClientCodegenTest covering both fixes and Angular 9 backwards compatibility. Relates to #7790 Relates to #6805 --- .../TypeScriptAngularClientCodegen.java | 87 +++++++++++++- .../typescript-angular/models.mustache | 10 ++ .../TypeScriptAngularClientCodegenTest.java | 106 ++++++++++++++++++ 3 files changed, 197 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java index 8d331f308d71..1f44beb2adda 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java @@ -296,6 +296,7 @@ public void processOpts() { additionalProperties.put(NG_VERSION, ngVersion); additionalProperties.put("ngVersionAtLeast_17", ngVersionAtLeast_17); + additionalProperties.put("supportsExportType", ngVersion.atLeast("10.0.0")); if (additionalProperties.containsKey(API_MODULE_PREFIX)) { String apiModulePrefix = additionalProperties.get(API_MODULE_PREFIX).toString(); @@ -519,17 +520,91 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L operations.put("hasSomeEncodableParams", hasSomeEncodableParams); operations.put("hasSomeHttpResource", hasSomeHttpResource); - // Add additional filename information for model imports in the services - List> imports = operations.getImports(); - for (Map im : imports) { - // This property is not used in the templates any more, subject for removal - im.put("filename", im.get("import")); - im.put("classname", im.get("classname")); + // Recompute model imports in services so all types from the same module are imported + Set allImports = new TreeSet<>(); + for (CodegenOperation op : ops) { + if (op.imports != null) { + allImports.addAll(op.imports); + } } + Set> serviceImports = new TreeSet<>( + Comparator.comparing((Map o) -> o.getOrDefault("classname", "")) + .thenComparing(o -> o.getOrDefault("filename", "")) + ); + + for (String nextImport : allImports) { + String mapping = importMapping.get(nextImport); + if (mapping != null) { + Map im = new LinkedHashMap<>(); + im.put("filename", mapping); + im.put("import", mapping); + im.put("classname", nextImport); + serviceImports.add(im); + } else { + Map modelImports = toModelImportMap(nextImport); + for (Map.Entry entry : modelImports.entrySet()) { + Map im = new LinkedHashMap<>(); + im.put("filename", entry.getKey()); + im.put("import", entry.getKey()); + im.put("classname", entry.getValue()); + serviceImports.add(im); + } + } + } + + List> importsList = new ArrayList<>(serviceImports); + operations.setImports(importsList); + operations.put("hasImport", !importsList.isEmpty()); + return operations; } + @Override + public Map postProcessSupportingFileData(Map objs) { + objs = super.postProcessSupportingFileData(objs); + + @SuppressWarnings("unchecked") + List models = (List) objs.get("models"); + if (models == null) { + return objs; + } + + Map> externalImports = new TreeMap<>(); + for (ModelMap mo : models) { + CodegenModel cm = mo.getModel(); + if (cm == null) { + continue; + } + + String modelName = cm.classname != null ? cm.classname : cm.name; + String target = importMapping.get(modelName); + if (target == null && cm.name != null) { + target = importMapping.get(cm.name); + } + if (target == null) { + continue; + } + + cm.vendorExtensions.put("x-is-external-model", true); + externalImports.computeIfAbsent(target, k -> new TreeSet<>()).add(modelName); + } + + List> externalModelExports = new ArrayList<>(); + for (Map.Entry> entry : externalImports.entrySet()) { + Map exportItem = new HashMap<>(); + exportItem.put("module", entry.getKey()); + exportItem.put("types", String.join(", ", entry.getValue())); + externalModelExports.add(exportItem); + } + + objs.put("hasExternalModelExports", !externalModelExports.isEmpty()); + objs.put("externalModelExports", externalModelExports); + objs.put("supportsExportType", additionalProperties.getOrDefault("supportsExportType", true)); + + return objs; + } + /** * Marks a GET operation without a request body for an additional httpResource method. * The factory mirrors the responseType the Observable method picks at runtime for the default diff --git a/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache b/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache index 995ffee16216..00123d32f9fd 100644 --- a/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache @@ -1,5 +1,15 @@ {{#models}} {{#model}} +{{^vendorExtensions.x-is-external-model}} export * from '{{{ classFilename }}}'; +{{/vendorExtensions.x-is-external-model}} {{/model}} {{/models}} +{{#externalModelExports}} +{{#supportsExportType}} +export type { {{{ types }}} } from '{{{ module }}}'; +{{/supportsExportType}} +{{^supportsExportType}} +export { {{{ types }}} } from '{{{ module }}}'; +{{/supportsExportType}} +{{/externalModelExports}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java index dfe632184f65..923f7341dc96 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java @@ -682,4 +682,110 @@ public void testHttpResourceSkipsAnOperationWhoseNameIsTaken() throws IOExceptio TestUtils.assertFileContains(service, "public getPetResourceResource(options?: ApiHttpResourceOptions)"); TestUtils.assertFileNotContains(service, "public getPetResource(options?: ApiHttpResourceOptions)"); } + + @Test + public void testMultipleImportsFromSameModuleAndExternalModelReExport() throws IOException { + Path spec = Files.createTempFile("external-imports-spec", ".yaml"); + spec.toFile().deleteOnExit(); + Files.writeString(spec, String.join("\n", + "openapi: 3.0.1", + "info: {title: test, version: 1.0.0}", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: ok", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/ItemDto'", + " post:", + " operationId: createItem", + " requestBody:", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/ItemCreateRequest'", + " responses:", + " '200':", + " description: ok", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/ItemDto'", + "components:", + " schemas:", + " ItemDto:", + " type: object", + " properties:", + " id:", + " type: string", + " ItemCreateRequest:", + " type: object", + " properties:", + " name:", + " type: string", + " LocalModel:", + " type: object", + " properties:", + " localProp:", + " type: string" + )); + + File output = Files.createTempDirectory("test-ext-imports").toFile(); + output.deleteOnExit(); + + Map importMappings = new HashMap<>(); + importMappings.put("ItemDto", "@contracts"); + importMappings.put("ItemCreateRequest", "@contracts"); + + Map typeMappings = new HashMap<>(); + typeMappings.put("ItemDto", "ItemDto"); + typeMappings.put("ItemCreateRequest", "ItemCreateRequest"); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-angular") + .setInputSpec(spec.toString()) + .setImportMappings(importMappings) + .setTypeMappings(typeMappings) + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path defaultService = Paths.get(output + "/api/default.service.ts"); + TestUtils.assertFileContains(defaultService, + "import { ItemCreateRequest } from '@contracts';", + "import { ItemDto } from '@contracts';"); + + Path modelsFile = Paths.get(output + "/model/models.ts"); + TestUtils.assertFileContains(modelsFile, + "export * from './localModel';", + "export type { ItemCreateRequest, ItemDto } from '@contracts';"); + TestUtils.assertFileNotContains(modelsFile, + "export * from '@contracts';"); + + // Test backwards compatibility with Angular 9 (TypeScript < 3.8 without 'export type') + File outputNg9 = Files.createTempDirectory("test-ext-imports-ng9").toFile(); + outputNg9.deleteOnExit(); + + final CodegenConfigurator configuratorNg9 = new CodegenConfigurator() + .setGeneratorName("typescript-angular") + .setInputSpec(spec.toString()) + .setImportMappings(importMappings) + .setTypeMappings(typeMappings) + .addAdditionalProperty(TypeScriptAngularClientCodegen.NG_VERSION, "9.0.0") + .setOutputDir(outputNg9.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configuratorNg9.toClientOptInput()).generate(); + + Path modelsFileNg9 = Paths.get(outputNg9 + "/model/models.ts"); + TestUtils.assertFileContains(modelsFileNg9, + "export * from './localModel';", + "export { ItemCreateRequest, ItemDto } from '@contracts';"); + TestUtils.assertFileNotContains(modelsFileNg9, + "export type {", + "export * from '@contracts';"); + } } From dd874389a9517531e9dbf320c6a4c3cc8f2860c9 Mon Sep 17 00:00:00 2001 From: slaesh Date: Sat, 24 Oct 2026 09:54:48 +0200 Subject: [PATCH 2/2] [typescript-angular] Deduplicate external model export * and add test for #22533 --- .../TypeScriptAngularClientCodegen.java | 2 - .../typescript-angular/models.mustache | 7 +- .../TypeScriptAngularClientCodegenTest.java | 91 +++++++++++++++++-- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java index 1f44beb2adda..8d5ba93e0d87 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java @@ -296,7 +296,6 @@ public void processOpts() { additionalProperties.put(NG_VERSION, ngVersion); additionalProperties.put("ngVersionAtLeast_17", ngVersionAtLeast_17); - additionalProperties.put("supportsExportType", ngVersion.atLeast("10.0.0")); if (additionalProperties.containsKey(API_MODULE_PREFIX)) { String apiModulePrefix = additionalProperties.get(API_MODULE_PREFIX).toString(); @@ -600,7 +599,6 @@ public Map postProcessSupportingFileData(Map obj objs.put("hasExternalModelExports", !externalModelExports.isEmpty()); objs.put("externalModelExports", externalModelExports); - objs.put("supportsExportType", additionalProperties.getOrDefault("supportsExportType", true)); return objs; } diff --git a/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache b/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache index 00123d32f9fd..7b9c04d73f39 100644 --- a/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-angular/models.mustache @@ -6,10 +6,5 @@ export * from '{{{ classFilename }}}'; {{/model}} {{/models}} {{#externalModelExports}} -{{#supportsExportType}} -export type { {{{ types }}} } from '{{{ module }}}'; -{{/supportsExportType}} -{{^supportsExportType}} -export { {{{ types }}} } from '{{{ module }}}'; -{{/supportsExportType}} +export * from '{{{ module }}}'; {{/externalModelExports}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java index 923f7341dc96..7ab2d2243851 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java @@ -762,11 +762,13 @@ public void testMultipleImportsFromSameModuleAndExternalModelReExport() throws I Path modelsFile = Paths.get(output + "/model/models.ts"); TestUtils.assertFileContains(modelsFile, "export * from './localModel';", - "export type { ItemCreateRequest, ItemDto } from '@contracts';"); - TestUtils.assertFileNotContains(modelsFile, "export * from '@contracts';"); + String modelsContent = Files.readString(modelsFile); + int firstIdx = modelsContent.indexOf("export * from '@contracts';"); + int lastIdx = modelsContent.lastIndexOf("export * from '@contracts';"); + org.testng.Assert.assertEquals(firstIdx, lastIdx, "Expected '@contracts' to be re-exported exactly once without duplicate lines"); - // Test backwards compatibility with Angular 9 (TypeScript < 3.8 without 'export type') + // Test backwards compatibility with Angular 9 File outputNg9 = Files.createTempDirectory("test-ext-imports-ng9").toFile(); outputNg9.deleteOnExit(); @@ -783,9 +785,86 @@ public void testMultipleImportsFromSameModuleAndExternalModelReExport() throws I Path modelsFileNg9 = Paths.get(outputNg9 + "/model/models.ts"); TestUtils.assertFileContains(modelsFileNg9, "export * from './localModel';", - "export { ItemCreateRequest, ItemDto } from '@contracts';"); - TestUtils.assertFileNotContains(modelsFileNg9, - "export type {", "export * from '@contracts';"); + String modelsContentNg9 = Files.readString(modelsFileNg9); + int firstNg9 = modelsContentNg9.indexOf("export * from '@contracts';"); + int lastNg9 = modelsContentNg9.lastIndexOf("export * from '@contracts';"); + org.testng.Assert.assertEquals(firstNg9, lastNg9, "Expected '@contracts' to be re-exported exactly once in Angular 9"); + } + + @Test + public void testIssue22533ImportMappingsSamePathInServices() throws IOException { + Path spec = Files.createTempFile("issue-22533-spec", ".yaml"); + spec.toFile().deleteOnExit(); + Files.writeString(spec, String.join("\n", + "openapi: 3.0.0", + "info: {title: Sample API, version: '0'}", + "paths:", + " /users/{foo}/{bar}:", + " parameters:", + " - name: foo", + " in: path", + " required: true", + " schema:", + " $ref: '#/components/schemas/Foo'", + " - name: bar", + " in: path", + " required: true", + " schema:", + " $ref: '#/components/schemas/Bar'", + " get:", + " operationId: getUsers", + " responses:", + " '200':", + " description: ok", + " content:", + " application/json:", + " schema:", + " type: array", + " items:", + " type: string", + "components:", + " schemas:", + " Foo:", + " type: object", + " properties:", + " id: {type: string}", + " Bar:", + " type: object", + " properties:", + " id: {type: string}" + )); + + File output = Files.createTempDirectory("test-issue-22533").toFile(); + output.deleteOnExit(); + + Map importMappings = new HashMap<>(); + importMappings.put("Foo", "@foo/bar"); + importMappings.put("Bar", "@foo/bar"); + + Map typeMappings = new HashMap<>(); + typeMappings.put("Foo", "Foo"); + typeMappings.put("Bar", "Bar"); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-angular") + .setInputSpec(spec.toString()) + .setImportMappings(importMappings) + .setTypeMappings(typeMappings) + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path defaultService = Paths.get(output + "/api/default.service.ts"); + TestUtils.assertFileContains(defaultService, + "import { Bar } from '@foo/bar';", + "import { Foo } from '@foo/bar';"); + + Path modelsFile = Paths.get(output + "/model/models.ts"); + TestUtils.assertFileContains(modelsFile, "export * from '@foo/bar';"); + String modelsContent = Files.readString(modelsFile); + int firstIdx = modelsContent.indexOf("export * from '@foo/bar';"); + int lastIdx = modelsContent.lastIndexOf("export * from '@foo/bar';"); + org.testng.Assert.assertEquals(firstIdx, lastIdx, "Expected '@foo/bar' to be re-exported exactly once"); } }