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..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 @@ -519,17 +519,90 @@ 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); + + 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..7b9c04d73f39 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,10 @@ {{#models}} {{#model}} +{{^vendorExtensions.x-is-external-model}} export * from '{{{ classFilename }}}'; +{{/vendorExtensions.x-is-external-model}} {{/model}} {{/models}} +{{#externalModelExports}} +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 dfe632184f65..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 @@ -682,4 +682,189 @@ 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 * 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 + 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 * 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"); + } }