Generate merge spec (#14387)

This commit is contained in:
Oleh Kurpiak 2023-02-17 04:40:05 +02:00 committed by GitHub
parent 0816008f1e
commit 2bc963f00c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 490 additions and 2 deletions

View File

@ -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")

View File

@ -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<String>()
/**
* Local root folder with spec files
*/
@Optional
@get:InputFile
@PathSensitive(PathSensitivity.RELATIVE)
val inputSpecRootDirectory = project.objects.property<String>();
/**
* Name of the file that will contains all merged specs
*/
@Input
@Optional
val mergedFileName = project.objects.property<String>();
/**
* 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)

View File

@ -125,8 +125,6 @@
<properties>
<swagger-annotations-version>1.5.8</swagger-annotations-version>
<spring-boot-starter-web.version>2.2.1.RELEASE</spring-boot-starter-web.version>
<springfox-version>2.8.0</springfox-version>
</properties>
</project>

View File

@ -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) {

View File

@ -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<String> 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<SpecWithPaths> 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<String, Object> 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<String, Object> generatedMergedSpec(String openapiVersion, List<SpecWithPaths> allPaths) {
Map<String, Object> spec = generateHeader(openapiVersion);
Map<String, Object> 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<String, Object> generateHeader(String openapiVersion) {
Map<String, Object> 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<String> 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<String> paths;
private SpecWithPaths(final String specRelatedPath, final Set<String> paths) {
this.specRelatedPath = specRelatedPath;
this.paths = paths;
}
}
}

View File

@ -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<String, File> files = generator.opts(input).generate().stream()
.collect(Collectors.toMap(File::getName, Function.identity()));
JavaFileAssert.assertThat(files.get("Spec1Api.java"))
.assertMethod("spec1Operation").hasReturnType("ResponseEntity<Spec1Model>")
.toFileAssert()
.assertMethod("spec1OperationComplex")
.hasReturnType("ResponseEntity<Spec1Model>")
.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<Spec2Model>");
JavaFileAssert.assertThat(files.get("Spec1Model.java"))
.assertMethod("getSpec1Field").hasReturnType("String");
JavaFileAssert.assertThat(files.get("Spec2Model.java"))
.assertMethod("getSpec2Field").hasReturnType("BigDecimal");
}
}

View File

@ -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"
}
}
}
}
}
}

View File

@ -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

View File

@ -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"
}
}
}
}
}
}

View File

@ -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