diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 2d473df2a3e..4bfca92f02b 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -32,6 +32,7 @@ import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.openapitools.codegen.*; import org.openapitools.codegen.config.CodegenConfigurator; +import org.openapitools.codegen.config.MergedSpecBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,6 +58,13 @@ public class Generate extends OpenApiGeneratorCommand { description = "location of the OpenAPI spec, as URL or file (required if not loaded via config using -c)") private String spec; + @Option(name = "--input-spec-root-directory", title = "Folder with spec(s)", + description = "Local root folder with spec file(s)") + private String inputSpecRootDirectory; + + @Option(name = "--merged-spec-filename", title = "Name of resulted merged specs file (used along with --input-spec-root-directory option)") + private String mergedFileName; + @Option(name = {"-t", "--template-dir"}, title = "template directory", description = "folder containing the template files") private String templateDir; @@ -283,6 +291,12 @@ public class Generate extends OpenApiGeneratorCommand { @Override public void execute() { + if (StringUtils.isNotBlank(inputSpecRootDirectory)) { + spec = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName) + .buildMergedSpec(); + System.out.println("Merge input spec would be used - " + spec); + } + if (logToStderr != null) { LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); Stream.of(Logger.ROOT_LOGGER_NAME, "io.swagger", "org.openapitools") diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index b368945e77c..911f9adb801 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -39,6 +39,7 @@ import org.openapitools.codegen.CodegenConstants import org.openapitools.codegen.DefaultGenerator import org.openapitools.codegen.config.CodegenConfigurator import org.openapitools.codegen.config.GlobalSettings +import org.openapitools.codegen.config.MergedSpecBuilder /** * A task which generates the desired code. @@ -96,6 +97,21 @@ open class GenerateTask : DefaultTask() { @PathSensitive(PathSensitivity.RELATIVE) val inputSpec = project.objects.property() + /** + * Local root folder with spec files + */ + @Optional + @get:InputFile + @PathSensitive(PathSensitivity.RELATIVE) + val inputSpecRootDirectory = project.objects.property(); + + /** + * Name of the file that will contains all merged specs + */ + @Input + @Optional + val mergedFileName = project.objects.property(); + /** * The remote Open API 2.0/3.x specification URL location. */ @@ -527,6 +543,11 @@ open class GenerateTask : DefaultTask() { @Suppress("unused") @TaskAction fun doWork() { + inputSpecRootDirectory.ifNotEmpty { inputSpecRootDirectoryValue -> { + inputSpec.set(MergedSpecBuilder(inputSpecRootDirectoryValue, mergedFileName.get()).buildMergedSpec()) + logger.info("Merge input spec would be used - {}", inputSpec.get()) + }} + cleanupOutput.ifNotEmpty { cleanup -> if (cleanup) { project.delete(outputDir) diff --git a/modules/openapi-generator-maven-plugin/examples/spring.xml b/modules/openapi-generator-maven-plugin/examples/spring.xml index 6508042ca50..f9c7a7f70a8 100644 --- a/modules/openapi-generator-maven-plugin/examples/spring.xml +++ b/modules/openapi-generator-maven-plugin/examples/spring.xml @@ -125,8 +125,6 @@ 1.5.8 - - 2.2.1.RELEASE 2.8.0 diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 8eb2073faa6..48f2d6ee07d 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -59,6 +59,7 @@ import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.auth.AuthParser; import org.openapitools.codegen.config.CodegenConfigurator; import org.openapitools.codegen.config.GlobalSettings; +import org.openapitools.codegen.config.MergedSpecBuilder; import org.sonatype.plexus.build.incremental.BuildContext; import org.sonatype.plexus.build.incremental.DefaultBuildContext; import org.slf4j.Logger; @@ -104,6 +105,18 @@ public class CodeGenMojo extends AbstractMojo { @Parameter(name = "inputSpec", property = "openapi.generator.maven.plugin.inputSpec", required = true) private String inputSpec; + /** + * Local root folder with spec files + */ + @Parameter(name = "inputSpecRootDirectory", property = "openapi.generator.maven.plugin.inputSpecRootDirectory") + private String inputSpecRootDirectory; + + /** + * Name of the file that will contains all merged specs + */ + @Parameter(name = "mergedFileName", property = "openapi.generator.maven.plugin.mergedFileName", defaultValue = "_merged_spec") + private String mergedFileName; + /** * Git host, e.g. gitlab.com. */ @@ -468,6 +481,12 @@ public class CodeGenMojo extends AbstractMojo { @Override public void execute() throws MojoExecutionException { + if (StringUtils.isNotBlank(inputSpecRootDirectory)) { + inputSpec = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName) + .buildMergedSpec(); + LOGGER.info("Merge input spec would be used - {}", inputSpec); + } + File inputSpecFile = new File(inputSpec); if (output == null) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java new file mode 100644 index 00000000000..356474f0f75 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -0,0 +1,151 @@ +package org.openapitools.codegen.config; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.collect.ImmutableMap; + +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.core.models.ParseOptions; + +public class MergedSpecBuilder { + + private static final Logger LOGGER = LoggerFactory.getLogger(MergedSpecBuilder.class); + + private final String inputSpecRootDirectory; + private final String mergeFileName; + + public MergedSpecBuilder(final String rootDirectory, final String mergeFileName) { + this.inputSpecRootDirectory = rootDirectory; + this.mergeFileName = mergeFileName; + } + + public String buildMergedSpec() { + deleteMergedFileFromPreviousRun(); + List specRelatedPaths = getAllSpecFilesInDirectory(); + if (specRelatedPaths.isEmpty()) { + throw new RuntimeException("Spec directory doesn't contains any specification"); + } + LOGGER.info("In spec root directory {} found specs {}", inputSpecRootDirectory, specRelatedPaths); + + String openapiVersion = null; + boolean isJson = false; + ParseOptions options = new ParseOptions(); + options.setResolve(true); + List allPaths = new ArrayList<>(); + + for (String specRelatedPath : specRelatedPaths) { + String specPath = inputSpecRootDirectory + File.separator + specRelatedPath; + try { + LOGGER.info("Reading spec: {}", specPath); + + OpenAPI result = new OpenAPIParser() + .readLocation(specPath, new ArrayList<>(), options) + .getOpenAPI(); + + if (openapiVersion == null) { + openapiVersion = result.getOpenapi(); + if (specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { + isJson = true; + } + } + allPaths.add(new SpecWithPaths(specRelatedPath, result.getPaths().keySet())); + } catch (Exception e) { + LOGGER.error("Failed to read file: {}. It would be ignored", specPath); + } + } + + Map mergedSpec = generatedMergedSpec(openapiVersion, allPaths); + String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); + Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); + + try { + ObjectMapper objectMapper = isJson ? new ObjectMapper() : new ObjectMapper(new YAMLFactory()); + Files.write(mergedFilePath, objectMapper.writeValueAsBytes(mergedSpec), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return mergedFilePath.toString(); + } + + private static Map generatedMergedSpec(String openapiVersion, List allPaths) { + Map spec = generateHeader(openapiVersion); + Map paths = new HashMap<>(); + spec.put("paths", paths); + + for(SpecWithPaths specWithPaths : allPaths) { + for (String path : specWithPaths.paths) { + String specRelatedPath = "./" + specWithPaths.specRelatedPath + "#/paths/" + path.replace("/", "~1"); + paths.put(path, ImmutableMap.of( + "$ref", specRelatedPath + )); + } + } + + return spec; + } + + private static Map generateHeader(String openapiVersion) { + Map map = new HashMap<>(); + map.put("openapi", openapiVersion); + map.put("info", ImmutableMap.of( + "title", "merged spec", + "description", "merged spec", + "version", "1.0.0" + )); + map.put("servers", Collections.singleton( + ImmutableMap.of("url", "http://localhost:8080") + )); + return map; + } + + private List getAllSpecFilesInDirectory() { + Path rootDirectory = new File(inputSpecRootDirectory).toPath(); + try { + return Files.walk(rootDirectory) + .filter(path -> !Files.isDirectory(path)) + .map(path -> rootDirectory.relativize(path).toString()) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new RuntimeException("Exception while listing files in spec root directory: " + inputSpecRootDirectory, e); + } + } + + private void deleteMergedFileFromPreviousRun() { + try { + Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".json")); + } catch (IOException e) { } + try { + Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".yaml")); + } catch (IOException e) { } + } + + private static class SpecWithPaths { + private final String specRelatedPath; + private final Set paths; + + private SpecWithPaths(final String specRelatedPath, final Set paths) { + this.specRelatedPath = specRelatedPath; + this.paths = paths; + } + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java new file mode 100644 index 00000000000..a7d6db4c0f4 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -0,0 +1,95 @@ +package org.openapitools.codegen.config; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.java.assertions.JavaFileAssert; +import org.openapitools.codegen.languages.SpringCodegen; +import org.testng.annotations.Test; + +import com.google.common.collect.ImmutableMap; + +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.core.models.ParseOptions; + +public class MergedSpecBuilderTest { + + @Test + public void shouldMergeYamlSpecs() throws IOException { + mergeSpecs("yaml"); + } + + @Test + public void shouldMergeJsonSpecs() throws IOException { + mergeSpecs("json"); + } + + private void mergeSpecs(String fileExt) throws IOException { + File output = Files.createTempDirectory("spec-directory").toFile().getCanonicalFile(); + output.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), output.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec2." + fileExt), output.toPath().resolve("spec2." + fileExt)); + + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + String mergedSpec = new MergedSpecBuilder(outputPath, "_merged_file") + .buildMergedSpec(); + + assertFilesFromMergedSpec(mergedSpec); + } + + private void assertFilesFromMergedSpec(String mergedSpec) throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser() + .readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + SpringCodegen codegen = new SpringCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + + ClientOptInput input = new ClientOptInput(); + input.openAPI(openAPI); + input.config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(files.get("Spec1Api.java")) + .assertMethod("spec1Operation").hasReturnType("ResponseEntity") + + .toFileAssert() + + .assertMethod("spec1OperationComplex") + .hasReturnType("ResponseEntity") + .assertMethodAnnotations() + .containsWithNameAndAttributes("RequestMapping", ImmutableMap.of("value", "\"/spec1/complex/{param1}/path\"")) + .toMethod() + .hasParameter("param1") + .withType("String") + .assertParameterAnnotations() + .containsWithNameAndAttributes("PathVariable", ImmutableMap.of("value", "\"param1\"")); + + JavaFileAssert.assertThat(files.get("Spec2Api.java")) + .assertMethod("spec2Operation").hasReturnType("ResponseEntity"); + + JavaFileAssert.assertThat(files.get("Spec1Model.java")) + .assertMethod("getSpec1Field").hasReturnType("String"); + + JavaFileAssert.assertThat(files.get("Spec2Model.java")) + .assertMethod("getSpec2Field").hasReturnType("BigDecimal"); + } + +} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec1.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec1.json new file mode 100644 index 00000000000..b29f7847b3f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec1.json @@ -0,0 +1,74 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "description": "Specification to reproduce nullable issue with Array", + "title": "ArrayNullableTest Api" + }, + "paths": { + "/spec1": { + "get": { + "tags": [ + "spec1" + ], + "summary": "dummy", + "operationId": "spec1Operation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Spec1Model" + } + } + } + } + } + } + }, + "/spec1/complex/{param1}/path": { + "get": { + "tags": [ + "spec1" + ], + "summary": "dummy", + "operationId": "spec1OperationComplex", + "parameters": [ + { + "in": "path", + "name": "param1", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Spec1Model" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Spec1Model": { + "type": "object", + "properties": { + "spec1Field": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec1.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec1.yaml new file mode 100644 index 00000000000..d177198cedd --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec1.yaml @@ -0,0 +1,46 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + description: Specification to reproduce nullable issue with Array + title: ArrayNullableTest Api +paths: + /spec1: + get: + tags: + - spec1 + summary: dummy + operationId: spec1Operation + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + /spec1/complex/{param1}/path: + get: + tags: + - spec1 + summary: dummy + operationId: spec1OperationComplex + parameters: + - in: path + name: param1 + schema: + type: string + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + +components: + schemas: + Spec1Model: + type: object + properties: + spec1Field: + type: string \ No newline at end of file diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec2.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec2.json new file mode 100644 index 00000000000..b7d83e5707b --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec2.json @@ -0,0 +1,43 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "description": "Specification to reproduce nullable issue with Array", + "title": "ArrayNullableTest Api" + }, + "paths": { + "/spec2": { + "get": { + "tags": [ + "spec2" + ], + "summary": "dummy", + "operationId": "spec2Operation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Spec2Model" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Spec2Model": { + "type": "object", + "properties": { + "spec2Field": { + "type": "number" + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec2.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec2.yaml new file mode 100644 index 00000000000..bcd5f9a66b9 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec2.yaml @@ -0,0 +1,27 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + description: Specification to reproduce nullable issue with Array + title: ArrayNullableTest Api +paths: + /spec2: + get: + tags: + - spec2 + summary: dummy + operationId: spec2Operation + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Spec2Model' + +components: + schemas: + Spec2Model: + type: object + properties: + spec2Field: + type: number \ No newline at end of file