diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIGenerator.java index e6e724f7ab23..7299e78caa52 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIGenerator.java @@ -22,6 +22,7 @@ import org.openapitools.codegen.*; import org.openapitools.codegen.meta.features.*; import org.openapitools.codegen.serializer.SerializerUtils; +import org.openapitools.codegen.utils.OpenAPISorter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,10 +32,12 @@ public class OpenAPIGenerator extends DefaultCodegen implements CodegenConfig { public static final String OUTPUT_NAME = "outputFileName"; + public static final String SORT_OUTPUT = "sortOutput"; private final Logger LOGGER = LoggerFactory.getLogger(OpenAPIGenerator.class); protected String outputFileName = "openapi.json"; + protected boolean sortOutput = false; public OpenAPIGenerator() { super(); @@ -55,6 +58,10 @@ public OpenAPIGenerator() { supportingFiles.add(new SupportingFile("README.md", "", "README.md")); cliOptions.add(CliOption.newString(OUTPUT_NAME, "Output file name").defaultValue(outputFileName)); + cliOptions.add(CliOption.newBoolean(SORT_OUTPUT, + "Sort paths alphabetically, schemas/parameters by name, and HTTP methods in classical order " + + "(GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).") + .defaultValue(Boolean.FALSE.toString())); } @Override @@ -80,11 +87,18 @@ public void processOpts() { outputFileName = additionalProperties.get(OUTPUT_NAME).toString(); } LOGGER.info("Output file name [outputFileName={}]", outputFileName); + + if (additionalProperties.containsKey(SORT_OUTPUT)) { + sortOutput = Boolean.parseBoolean(additionalProperties.get(SORT_OUTPUT).toString()); + } } @Override public void processOpenAPI(OpenAPI openAPI) { - String jsonOpenAPI = SerializerUtils.toJsonString(openAPI); + if (sortOutput) { + OpenAPISorter.sort(openAPI); + } + String jsonOpenAPI = SerializerUtils.toJsonString(openAPI, sortOutput); try { String outputFile = outputFolder + File.separator + outputFileName; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java index 8bd430450224..25a91e634b8f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java @@ -19,10 +19,13 @@ import com.google.common.collect.ImmutableMap; import com.samskivert.mustache.Mustache.Lambda; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import org.openapitools.codegen.*; import org.openapitools.codegen.meta.features.*; +import org.openapitools.codegen.serializer.SerializerUtils; import org.openapitools.codegen.templating.mustache.OnChangeLambda; +import org.openapitools.codegen.utils.OpenAPISorter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,10 +36,12 @@ public class OpenAPIYamlGenerator extends DefaultCodegen implements CodegenConfig { public static final String OUTPUT_NAME = "outputFile"; + public static final String SORT_OUTPUT = "sortOutput"; private final Logger LOGGER = LoggerFactory.getLogger(OpenAPIYamlGenerator.class); protected String outputFile = "openapi/openapi.yaml"; + protected boolean sortOutput = false; public OpenAPIYamlGenerator() { super(); @@ -54,6 +59,10 @@ public OpenAPIYamlGenerator() { embeddedTemplateDir = templateDir = "openapi-yaml"; outputFolder = "generated-code/openapi-yaml"; cliOptions.add(CliOption.newString(OUTPUT_NAME, "Output filename").defaultValue(outputFile)); + cliOptions.add(CliOption.newBoolean(SORT_OUTPUT, + "Sort paths alphabetically, schemas/parameters by name, and HTTP methods in classical order " + + "(GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).") + .defaultValue(Boolean.FALSE.toString())); supportingFiles.add(new SupportingFile("README.md", "", "README.md")); } @@ -80,6 +89,17 @@ public void processOpts() { } LOGGER.info("Output file [outputFile={}]", outputFile); supportingFiles.add(new SupportingFile("openapi.mustache", outputFile)); + + if (additionalProperties.containsKey(SORT_OUTPUT)) { + sortOutput = Boolean.parseBoolean(additionalProperties.get(SORT_OUTPUT).toString()); + } + } + + @Override + public void processOpenAPI(OpenAPI openAPI) { + if (sortOutput) { + OpenAPISorter.sort(openAPI); + } } @Override @@ -100,6 +120,15 @@ public void addOperationToGroup(String tag, String resourcePath, Operation opera opList.add(co); } + @Override + public void generateYAMLSpecFile(Map objs) { + OpenAPI openAPI = (OpenAPI) objs.get("openAPI"); + String yaml = SerializerUtils.toYamlString(openAPI, sortOutput); + if (yaml != null) { + objs.put("openapi-yaml", yaml); + } + } + @Override public Map postProcessSupportingFileData(Map objs) { generateYAMLSpecFile(objs); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/PathItemSerializer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/PathItemSerializer.java new file mode 100644 index 000000000000..9adea7f45543 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/PathItemSerializer.java @@ -0,0 +1,65 @@ +package org.openapitools.codegen.serializer; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import io.swagger.v3.oas.models.PathItem; + +import java.io.IOException; +import java.util.Map; + +/** + * Serializes a {@link PathItem} with HTTP methods written in classical OpenAPI spec order: + * GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE — instead of Jackson's default + * alphabetical order (which would put DELETE before GET). + */ +public class PathItemSerializer extends JsonSerializer { + + @Override + public void serialize(PathItem value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + if (value.getSummary() != null) { + gen.writeStringField("summary", value.getSummary()); + } + if (value.getDescription() != null) { + gen.writeStringField("description", value.getDescription()); + } + // HTTP methods in classical OpenAPI spec order + if (value.getGet() != null) { + gen.writeObjectField("get", value.getGet()); + } + if (value.getPut() != null) { + gen.writeObjectField("put", value.getPut()); + } + if (value.getPost() != null) { + gen.writeObjectField("post", value.getPost()); + } + if (value.getDelete() != null) { + gen.writeObjectField("delete", value.getDelete()); + } + if (value.getOptions() != null) { + gen.writeObjectField("options", value.getOptions()); + } + if (value.getHead() != null) { + gen.writeObjectField("head", value.getHead()); + } + if (value.getPatch() != null) { + gen.writeObjectField("patch", value.getPatch()); + } + if (value.getTrace() != null) { + gen.writeObjectField("trace", value.getTrace()); + } + if (value.getServers() != null) { + gen.writeObjectField("servers", value.getServers()); + } + if (value.getParameters() != null) { + gen.writeObjectField("parameters", value.getParameters()); + } + if (value.getExtensions() != null) { + for (Map.Entry e : value.getExtensions().entrySet()) { + gen.writeObjectField(e.getKey(), e.getValue()); + } + } + gen.writeEndObject(); + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/SerializerUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/SerializerUtils.java index 2799f9828d61..0c95b539e30a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/SerializerUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/SerializerUtils.java @@ -9,6 +9,7 @@ import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; import org.openapitools.codegen.config.GlobalSettings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,10 +20,14 @@ public class SerializerUtils { private static final boolean minimizeYamlQuotes = Boolean.parseBoolean(GlobalSettings.getProperty(YAML_MINIMIZE_QUOTES_PROPERTY, "true")); public static String toYamlString(OpenAPI openAPI) { + return toYamlString(openAPI, false); + } + + public static String toYamlString(OpenAPI openAPI, boolean sortOutput) { if (openAPI == null) { return null; } - SimpleModule module = createModule(); + SimpleModule module = createModule(sortOutput); try { ObjectMapper yamlMapper = Yaml.mapper().copy(); // there is an unfortunate YAML condition where user inputs should be treated as strings (e.g. "1234_1234"), but in yaml this is a valid number and @@ -44,11 +49,15 @@ public static String toYamlString(OpenAPI openAPI) { } public static String toJsonString(OpenAPI openAPI) { + return toJsonString(openAPI, false); + } + + public static String toJsonString(OpenAPI openAPI, boolean sortOutput) { if (openAPI == null) { return null; } - SimpleModule module = createModule(); + SimpleModule module = createModule(sortOutput); try { return Json.mapper() .copy() @@ -63,10 +72,13 @@ public static String toJsonString(OpenAPI openAPI) { return null; } - private static SimpleModule createModule() { + private static SimpleModule createModule(boolean sortOutput) { SimpleModule module = new SimpleModule("OpenAPIModule"); module.addSerializer(OpenAPI.class, new OpenAPISerializer()); module.addSerializer(byte[].class, new ByteArraySerializer()); + if (sortOutput) { + module.addSerializer(PathItem.class, new PathItemSerializer()); + } return module; } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OpenAPISorter.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OpenAPISorter.java new file mode 100644 index 000000000000..3a525aecdc56 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OpenAPISorter.java @@ -0,0 +1,80 @@ +package org.openapitools.codegen.utils; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Paths; + +import java.util.TreeMap; + +/** + * Utility for sorting an {@link OpenAPI} model in-place before serialization. + * + *
    + *
  • Paths are sorted alphabetically by path string.
  • + *
  • All component maps (schemas, parameters, requestBodies, responses, headers, + * securitySchemes, examples, links, callbacks) are sorted alphabetically by name.
  • + *
  • HTTP method ordering within a path is handled by {@link org.openapitools.codegen.serializer.PathItemSerializer}, + * which writes operations in classical spec order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
  • + *
+ */ +public class OpenAPISorter { + + private OpenAPISorter() { + } + + public static void sort(OpenAPI openAPI) { + if (openAPI == null) { + return; + } + sortPaths(openAPI); + sortComponents(openAPI); + } + + private static void sortPaths(OpenAPI openAPI) { + if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { + return; + } + Paths sorted = new Paths(); + openAPI.getPaths().entrySet().stream() + .sorted(java.util.Map.Entry.comparingByKey()) + .forEach(e -> sorted.addPathItem(e.getKey(), e.getValue())); + if (openAPI.getPaths().getExtensions() != null) { + openAPI.getPaths().getExtensions().forEach(sorted::addExtension); + } + openAPI.setPaths(sorted); + } + + private static void sortComponents(OpenAPI openAPI) { + Components c = openAPI.getComponents(); + if (c == null) { + return; + } + if (c.getSchemas() != null) { + c.setSchemas(new TreeMap<>(c.getSchemas())); + } + if (c.getParameters() != null) { + c.setParameters(new TreeMap<>(c.getParameters())); + } + if (c.getRequestBodies() != null) { + c.setRequestBodies(new TreeMap<>(c.getRequestBodies())); + } + if (c.getResponses() != null) { + c.setResponses(new TreeMap<>(c.getResponses())); + } + if (c.getHeaders() != null) { + c.setHeaders(new TreeMap<>(c.getHeaders())); + } + if (c.getSecuritySchemes() != null) { + c.setSecuritySchemes(new TreeMap<>(c.getSecuritySchemes())); + } + if (c.getExamples() != null) { + c.setExamples(new TreeMap<>(c.getExamples())); + } + if (c.getLinks() != null) { + c.setLinks(new TreeMap<>(c.getLinks())); + } + if (c.getCallbacks() != null) { + c.setCallbacks(new TreeMap<>(c.getCallbacks())); + } + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/json/JsonGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/json/JsonGeneratorTest.java index bd1d611daad8..25e4146945e1 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/json/JsonGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/json/JsonGeneratorTest.java @@ -4,6 +4,7 @@ import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.config.CodegenConfigurator; import org.openapitools.codegen.languages.OpenAPIGenerator; +import org.testng.Assert; import org.testng.annotations.Test; import java.io.File; @@ -67,4 +68,78 @@ public void testGeneratePingOtherOutputFile() throws Exception { output.deleteOnExit(); } + + + @Test + public void testSortOutput() throws Exception { + File output = Files.createTempDirectory("test-sort-json").toFile(); + output.deleteOnExit(); + + Map properties = new HashMap<>(); + properties.put(OpenAPIGenerator.SORT_OUTPUT, "true"); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("openapi") + .setAdditionalProperties(properties) + .setInputSpec("src/test/resources/3_0/sort-output-test.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + String json = new String(Files.readAllBytes(Paths.get(output.getAbsolutePath(), "openapi.json"))); + + // Paths alphabetical: /animals, /mammals, /zebra + int idxAnimals = json.indexOf("\"/animals\""); + int idxMammals = json.indexOf("\"/mammals\""); + int idxZebra = json.indexOf("\"/zebra\""); + Assert.assertTrue(idxAnimals < idxMammals, "/animals must come before /mammals"); + Assert.assertTrue(idxMammals < idxZebra, "/mammals must come before /zebra"); + + // Schemas alphabetical: AnimalModel, MammalModel, ZebraModel + int idxAnimal = json.indexOf("\"AnimalModel\""); + int idxMammal = json.indexOf("\"MammalModel\""); + int idxZebraM = json.indexOf("\"ZebraModel\""); + Assert.assertTrue(idxAnimal < idxMammal, "AnimalModel must come before MammalModel"); + Assert.assertTrue(idxMammal < idxZebraM, "MammalModel must come before ZebraModel"); + + // Parameters alphabetical: aFilter, mPage, zLimit + int idxAFilter = json.indexOf("\"aFilter\""); + int idxMPage = json.indexOf("\"mPage\""); + int idxZLimit = json.indexOf("\"zLimit\""); + Assert.assertTrue(idxAFilter < idxMPage, "aFilter must come before mPage"); + Assert.assertTrue(idxMPage < idxZLimit, "mPage must come before zLimit"); + + // HTTP method order — GET before POST in /zebra (spec has POST first) + int zebraBlock = json.indexOf("\"/zebra\""); + Assert.assertTrue(json.indexOf("\"get\"", zebraBlock) < json.indexOf("\"post\"", zebraBlock), + "GET must appear before POST within /zebra"); + + // HTTP method order — GET before DELETE in /mammals (spec has DELETE first) + int mammalsBlock = json.indexOf("\"/mammals\""); + Assert.assertTrue(json.indexOf("\"get\"", mammalsBlock) < json.indexOf("\"delete\"", mammalsBlock), + "GET must appear before DELETE within /mammals"); + } + + @Test + public void testSortOutputDisabledPreservesOriginalOrder() throws Exception { + File output = Files.createTempDirectory("test-nosort-json").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("openapi") + .setAdditionalProperties(new HashMap<>()) + .setInputSpec("src/test/resources/3_0/sort-output-test.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + String json = new String(Files.readAllBytes(Paths.get(output.getAbsolutePath(), "openapi.json"))); + + // Without sortOutput, paths are in spec order: /zebra first, then /mammals, then /animals + int idxZebra = json.indexOf("\"/zebra\""); + int idxMammals = json.indexOf("\"/mammals\""); + int idxAnimals = json.indexOf("\"/animals\""); + Assert.assertTrue(idxZebra < idxMammals, "Without sortOutput /zebra must come before /mammals (spec order)"); + Assert.assertTrue(idxMammals < idxAnimals, "Without sortOutput /mammals must come before /animals (spec order)"); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java index 1bbc8dc39fce..df45a2e5ee73 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java @@ -185,4 +185,55 @@ public void testIssue19929() throws Exception { byte[] exampleBytes = (byte[]) dataSchema.getExample(); Assert.assertEquals(new String(exampleBytes, StandardCharsets.UTF_8), "aGVsbG8K"); } + + @Test + public void testSortOutput() throws Exception { + File output = Files.createTempDirectory("test-sort-yaml").toFile(); + output.deleteOnExit(); + + Map properties = new HashMap<>(); + properties.put(OpenAPIYamlGenerator.OUTPUT_NAME, "sorted.yaml"); + properties.put(OpenAPIYamlGenerator.SORT_OUTPUT, "true"); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("openapi-yaml") + .setAdditionalProperties(properties) + .setInputSpec("src/test/resources/3_0/sort-output-test.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + String yaml = new String(Files.readAllBytes(Path.of(output.getAbsolutePath(), "sorted.yaml")), StandardCharsets.UTF_8); + + // Paths alphabetical: /animals, /mammals, /zebra + int idxAnimals = yaml.indexOf("/animals:"); + int idxMammals = yaml.indexOf("/mammals:"); + int idxZebra = yaml.indexOf("/zebra:"); + Assert.assertTrue(idxAnimals < idxMammals, "/animals must come before /mammals"); + Assert.assertTrue(idxMammals < idxZebra, "/mammals must come before /zebra"); + + // Schemas alphabetical: AnimalModel, MammalModel, ZebraModel + int idxAnimal = yaml.indexOf("AnimalModel:"); + int idxMammal = yaml.indexOf("MammalModel:"); + int idxZebraM = yaml.indexOf("ZebraModel:"); + Assert.assertTrue(idxAnimal < idxMammal, "AnimalModel must come before MammalModel"); + Assert.assertTrue(idxMammal < idxZebraM, "MammalModel must come before ZebraModel"); + + // Parameters alphabetical: aFilter, mPage, zLimit + int idxAFilter = yaml.indexOf("aFilter:"); + int idxMPage = yaml.indexOf("mPage:"); + int idxZLimit = yaml.indexOf("zLimit:"); + Assert.assertTrue(idxAFilter < idxMPage, "aFilter must come before mPage"); + Assert.assertTrue(idxMPage < idxZLimit, "mPage must come before zLimit"); + + // HTTP method order — GET before POST in /zebra (spec has POST first) + int zebraBlock = yaml.indexOf("/zebra:"); + Assert.assertTrue(yaml.indexOf("get:", zebraBlock) < yaml.indexOf("post:", zebraBlock), + "GET must appear before POST within /zebra"); + + // HTTP method order — GET before DELETE in /mammals (spec has DELETE first) + int mammalsBlock = yaml.indexOf("/mammals:"); + Assert.assertTrue(yaml.indexOf("get:", mammalsBlock) < yaml.indexOf("delete:", mammalsBlock), + "GET must appear before DELETE within /mammals"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/sort-output-test.yaml b/modules/openapi-generator/src/test/resources/3_0/sort-output-test.yaml new file mode 100644 index 000000000000..7786204d99b4 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/sort-output-test.yaml @@ -0,0 +1,102 @@ +openapi: 3.0.0 +info: + title: Sort Output Test + version: 1.0.0 +servers: + - url: http://localhost:8080 +paths: + /zebra: + post: + operationId: createZebra + summary: Create a zebra + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ZebraModel' + responses: + '201': + description: Created + get: + operationId: listZebras + summary: List zebras + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ZebraModel' + /mammals: + delete: + operationId: deleteMammal + summary: Delete a mammal + responses: + '204': + description: No Content + get: + operationId: getMammal + summary: Get a mammal + parameters: + - $ref: '#/components/parameters/zLimit' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MammalModel' + /animals: + get: + operationId: listAnimals + summary: List animals + parameters: + - $ref: '#/components/parameters/mPage' + - $ref: '#/components/parameters/aFilter' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AnimalModel' +components: + schemas: + ZebraModel: + type: object + properties: + id: + type: integer + name: + type: string + MammalModel: + type: object + properties: + id: + type: integer + species: + type: string + AnimalModel: + type: object + properties: + id: + type: integer + type: + type: string + parameters: + zLimit: + name: limit + in: query + schema: + type: integer + mPage: + name: page + in: query + schema: + type: integer + aFilter: + name: filter + in: query + schema: + type: string