Skip to content

Commit c21a355

Browse files
PicazsooCopilot
andauthored
[openapi, openapi-yaml] feature - add sortOutput option to deterministically sort paths and schemas, http methods, etc... (#24037)
* feat(openapi, openapi-yaml): add sortOutput option Add a new 'sortOutput' generator option to the 'openapi' (JSON) and 'openapi-yaml' (YAML) documentation generators that produces a deterministically ordered spec: - Paths are sorted alphabetically by URL - Schemas, parameters, requestBodies, responses, headers, examples, links, callbacks and securitySchemes are sorted alphabetically by name - HTTP methods within each path are ordered by the classical convention: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE Implementation details: - OpenAPISorter: replaces Paths and all Components maps with TreeMaps - PathItemSerializer: custom Jackson serializer writing operations in classical HTTP method order (only registered when sortOutput=true) - SerializerUtils: overloaded toJsonString/toYamlString with sortOutput flag; createModule(boolean) registers PathItemSerializer when true - OpenAPIGenerator / OpenAPIYamlGenerator: wire the new option through to the serializer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(openapi, openapi-yaml): enhance output ordering tests for paths, schemas, and HTTP methods * remove forbidden method invocations * feat(openapi, openapi-yaml): add documentation. Remove factually not-working features from documentation * improve documentation --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 48ef603 commit c21a355

10 files changed

Lines changed: 454 additions & 20 deletions

File tree

docs/generators/openapi-yaml.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl
1717

1818
| Option | Description | Values | Default |
1919
| ------ | ----------- | ------ | ------- |
20-
|allowUnicodeIdentifiers|boolean, toggles whether unicode identifiers are allowed in names or not, default is false| |false|
21-
|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|<dl><dt>**false**</dt><dd>The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.</dd><dt>**true**</dt><dd>Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.</dd></dl>|true|
22-
|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true|
23-
|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response. With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.|<dl><dt>**false**</dt><dd>No changes to the enums are made, this is the default option.</dd><dt>**true**</dt><dd>With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the enum case sent by the server is not known by the client/spec, can safely be decoded to this case.</dd></dl>|false|
24-
|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|<dl><dt>**true**</dt><dd>The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.</dd><dt>**false**</dt><dd>The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.</dd></dl>|true|
2520
|outputFile|Output filename| |openapi/openapi.yaml|
26-
|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false|
27-
|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true|
28-
|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true|
21+
|sortOutput|Sort paths alphabetically, component maps (schemas, parameters, requestBodies, responses, headers, examples, links, callbacks, securitySchemes) by name, and HTTP methods in classical order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).| |false|
2922

3023
## IMPORT MAPPING
3124

docs/generators/openapi.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl
1717

1818
| Option | Description | Values | Default |
1919
| ------ | ----------- | ------ | ------- |
20-
|allowUnicodeIdentifiers|boolean, toggles whether unicode identifiers are allowed in names or not, default is false| |false|
21-
|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|<dl><dt>**false**</dt><dd>The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.</dd><dt>**true**</dt><dd>Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.</dd></dl>|true|
22-
|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true|
23-
|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response. With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.|<dl><dt>**false**</dt><dd>No changes to the enums are made, this is the default option.</dd><dt>**true**</dt><dd>With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the enum case sent by the server is not known by the client/spec, can safely be decoded to this case.</dd></dl>|false|
24-
|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|<dl><dt>**true**</dt><dd>The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.</dd><dt>**false**</dt><dd>The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.</dd></dl>|true|
2520
|outputFileName|Output file name| |openapi.json|
26-
|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false|
27-
|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true|
28-
|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true|
21+
|sortOutput|Sort paths alphabetically, component maps (schemas, parameters, requestBodies, responses, headers, examples, links, callbacks, securitySchemes) by name, and HTTP methods in classical order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).| |false|
2922

3023
## IMPORT MAPPING
3124

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIGenerator.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.openapitools.codegen.*;
2323
import org.openapitools.codegen.meta.features.*;
2424
import org.openapitools.codegen.serializer.SerializerUtils;
25+
import org.openapitools.codegen.utils.OpenAPISorter;
2526
import org.slf4j.Logger;
2627
import org.slf4j.LoggerFactory;
2728

@@ -31,10 +32,12 @@
3132

3233
public class OpenAPIGenerator extends DefaultCodegen implements CodegenConfig {
3334
public static final String OUTPUT_NAME = "outputFileName";
35+
public static final String SORT_OUTPUT = "sortOutput";
3436

3537
private final Logger LOGGER = LoggerFactory.getLogger(OpenAPIGenerator.class);
3638

3739
protected String outputFileName = "openapi.json";
40+
protected boolean sortOutput = false;
3841

3942
public OpenAPIGenerator() {
4043
super();
@@ -54,7 +57,13 @@ public OpenAPIGenerator() {
5457

5558
supportingFiles.add(new SupportingFile("README.md", "", "README.md"));
5659

60+
cliOptions.clear();
5761
cliOptions.add(CliOption.newString(OUTPUT_NAME, "Output file name").defaultValue(outputFileName));
62+
cliOptions.add(CliOption.newBoolean(SORT_OUTPUT,
63+
"Sort paths alphabetically, component maps (schemas, parameters, requestBodies, responses, " +
64+
"headers, examples, links, callbacks, securitySchemes) by name, and HTTP methods in classical " +
65+
"order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).")
66+
.defaultValue(Boolean.FALSE.toString()));
5867
}
5968

6069
@Override
@@ -80,11 +89,18 @@ public void processOpts() {
8089
outputFileName = additionalProperties.get(OUTPUT_NAME).toString();
8190
}
8291
LOGGER.info("Output file name [outputFileName={}]", outputFileName);
92+
93+
if (additionalProperties.containsKey(SORT_OUTPUT)) {
94+
sortOutput = Boolean.parseBoolean(additionalProperties.get(SORT_OUTPUT).toString());
95+
}
8396
}
8497

8598
@Override
8699
public void processOpenAPI(OpenAPI openAPI) {
87-
String jsonOpenAPI = SerializerUtils.toJsonString(openAPI);
100+
if (sortOutput) {
101+
OpenAPISorter.sort(openAPI);
102+
}
103+
String jsonOpenAPI = SerializerUtils.toJsonString(openAPI, sortOutput);
88104

89105
try {
90106
String outputFile = outputFolder + File.separator + outputFileName;

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@
1919

2020
import com.google.common.collect.ImmutableMap;
2121
import com.samskivert.mustache.Mustache.Lambda;
22+
import io.swagger.v3.oas.models.OpenAPI;
2223
import io.swagger.v3.oas.models.Operation;
2324
import org.openapitools.codegen.*;
2425
import org.openapitools.codegen.meta.features.*;
26+
import org.openapitools.codegen.serializer.SerializerUtils;
2527
import org.openapitools.codegen.templating.mustache.OnChangeLambda;
28+
import org.openapitools.codegen.utils.OpenAPISorter;
2629
import org.slf4j.Logger;
2730
import org.slf4j.LoggerFactory;
2831

@@ -33,10 +36,12 @@
3336

3437
public class OpenAPIYamlGenerator extends DefaultCodegen implements CodegenConfig {
3538
public static final String OUTPUT_NAME = "outputFile";
39+
public static final String SORT_OUTPUT = "sortOutput";
3640

3741
private final Logger LOGGER = LoggerFactory.getLogger(OpenAPIYamlGenerator.class);
3842

3943
protected String outputFile = "openapi/openapi.yaml";
44+
protected boolean sortOutput = false;
4045

4146
public OpenAPIYamlGenerator() {
4247
super();
@@ -53,7 +58,14 @@ public OpenAPIYamlGenerator() {
5358

5459
embeddedTemplateDir = templateDir = "openapi-yaml";
5560
outputFolder = "generated-code/openapi-yaml";
61+
62+
cliOptions.clear();
5663
cliOptions.add(CliOption.newString(OUTPUT_NAME, "Output filename").defaultValue(outputFile));
64+
cliOptions.add(CliOption.newBoolean(SORT_OUTPUT,
65+
"Sort paths alphabetically, component maps (schemas, parameters, requestBodies, responses, " +
66+
"headers, examples, links, callbacks, securitySchemes) by name, and HTTP methods in classical " +
67+
"order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).")
68+
.defaultValue(Boolean.FALSE.toString()));
5769
supportingFiles.add(new SupportingFile("README.md", "", "README.md"));
5870
}
5971

@@ -80,6 +92,17 @@ public void processOpts() {
8092
}
8193
LOGGER.info("Output file [outputFile={}]", outputFile);
8294
supportingFiles.add(new SupportingFile("openapi.mustache", outputFile));
95+
96+
if (additionalProperties.containsKey(SORT_OUTPUT)) {
97+
sortOutput = Boolean.parseBoolean(additionalProperties.get(SORT_OUTPUT).toString());
98+
}
99+
}
100+
101+
@Override
102+
public void processOpenAPI(OpenAPI openAPI) {
103+
if (sortOutput) {
104+
OpenAPISorter.sort(openAPI);
105+
}
83106
}
84107

85108
@Override
@@ -100,6 +123,15 @@ public void addOperationToGroup(String tag, String resourcePath, Operation opera
100123
opList.add(co);
101124
}
102125

126+
@Override
127+
public void generateYAMLSpecFile(Map<String, Object> objs) {
128+
OpenAPI openAPI = (OpenAPI) objs.get("openAPI");
129+
String yaml = SerializerUtils.toYamlString(openAPI, sortOutput);
130+
if (yaml != null) {
131+
objs.put("openapi-yaml", yaml);
132+
}
133+
}
134+
103135
@Override
104136
public Map<String, Object> postProcessSupportingFileData(Map<String, Object> objs) {
105137
generateYAMLSpecFile(objs);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package org.openapitools.codegen.serializer;
2+
3+
import com.fasterxml.jackson.core.JsonGenerator;
4+
import com.fasterxml.jackson.databind.JsonSerializer;
5+
import com.fasterxml.jackson.databind.SerializerProvider;
6+
import io.swagger.v3.oas.models.PathItem;
7+
8+
import java.io.IOException;
9+
import java.util.Map;
10+
11+
/**
12+
* Serializes a {@link PathItem} with HTTP methods written in classical OpenAPI spec order:
13+
* GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE — instead of Jackson's default
14+
* alphabetical order (which would put DELETE before GET).
15+
*/
16+
public class PathItemSerializer extends JsonSerializer<PathItem> {
17+
18+
@Override
19+
public void serialize(PathItem value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
20+
gen.writeStartObject();
21+
if (value.getSummary() != null) {
22+
gen.writeStringField("summary", value.getSummary());
23+
}
24+
if (value.getDescription() != null) {
25+
gen.writeStringField("description", value.getDescription());
26+
}
27+
// HTTP methods in classical OpenAPI spec order
28+
if (value.getGet() != null) {
29+
gen.writeObjectField("get", value.getGet());
30+
}
31+
if (value.getPut() != null) {
32+
gen.writeObjectField("put", value.getPut());
33+
}
34+
if (value.getPost() != null) {
35+
gen.writeObjectField("post", value.getPost());
36+
}
37+
if (value.getDelete() != null) {
38+
gen.writeObjectField("delete", value.getDelete());
39+
}
40+
if (value.getOptions() != null) {
41+
gen.writeObjectField("options", value.getOptions());
42+
}
43+
if (value.getHead() != null) {
44+
gen.writeObjectField("head", value.getHead());
45+
}
46+
if (value.getPatch() != null) {
47+
gen.writeObjectField("patch", value.getPatch());
48+
}
49+
if (value.getTrace() != null) {
50+
gen.writeObjectField("trace", value.getTrace());
51+
}
52+
if (value.getServers() != null) {
53+
gen.writeObjectField("servers", value.getServers());
54+
}
55+
if (value.getParameters() != null) {
56+
gen.writeObjectField("parameters", value.getParameters());
57+
}
58+
if (value.getExtensions() != null) {
59+
for (Map.Entry<String, Object> e : value.getExtensions().entrySet()) {
60+
gen.writeObjectField(e.getKey(), e.getValue());
61+
}
62+
}
63+
gen.writeEndObject();
64+
}
65+
}

modules/openapi-generator/src/main/java/org/openapitools/codegen/serializer/SerializerUtils.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import io.swagger.v3.core.util.Json;
1010
import io.swagger.v3.core.util.Yaml;
1111
import io.swagger.v3.oas.models.OpenAPI;
12+
import io.swagger.v3.oas.models.PathItem;
1213
import org.openapitools.codegen.config.GlobalSettings;
1314
import org.slf4j.Logger;
1415
import org.slf4j.LoggerFactory;
@@ -19,10 +20,14 @@ public class SerializerUtils {
1920
private static final boolean minimizeYamlQuotes = Boolean.parseBoolean(GlobalSettings.getProperty(YAML_MINIMIZE_QUOTES_PROPERTY, "true"));
2021

2122
public static String toYamlString(OpenAPI openAPI) {
23+
return toYamlString(openAPI, false);
24+
}
25+
26+
public static String toYamlString(OpenAPI openAPI, boolean sortOutput) {
2227
if (openAPI == null) {
2328
return null;
2429
}
25-
SimpleModule module = createModule();
30+
SimpleModule module = createModule(sortOutput);
2631
try {
2732
ObjectMapper yamlMapper = Yaml.mapper().copy();
2833
// 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) {
4449
}
4550

4651
public static String toJsonString(OpenAPI openAPI) {
52+
return toJsonString(openAPI, false);
53+
}
54+
55+
public static String toJsonString(OpenAPI openAPI, boolean sortOutput) {
4756
if (openAPI == null) {
4857
return null;
4958
}
5059

51-
SimpleModule module = createModule();
60+
SimpleModule module = createModule(sortOutput);
5261
try {
5362
return Json.mapper()
5463
.copy()
@@ -63,10 +72,13 @@ public static String toJsonString(OpenAPI openAPI) {
6372
return null;
6473
}
6574

66-
private static SimpleModule createModule() {
75+
private static SimpleModule createModule(boolean sortOutput) {
6776
SimpleModule module = new SimpleModule("OpenAPIModule");
6877
module.addSerializer(OpenAPI.class, new OpenAPISerializer());
6978
module.addSerializer(byte[].class, new ByteArraySerializer());
79+
if (sortOutput) {
80+
module.addSerializer(PathItem.class, new PathItemSerializer());
81+
}
7082
return module;
7183
}
7284
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package org.openapitools.codegen.utils;
2+
3+
import io.swagger.v3.oas.models.Components;
4+
import io.swagger.v3.oas.models.OpenAPI;
5+
import io.swagger.v3.oas.models.Paths;
6+
7+
import java.util.TreeMap;
8+
9+
/**
10+
* Utility for sorting an {@link OpenAPI} model in-place before serialization.
11+
*
12+
* <ul>
13+
* <li>Paths are sorted alphabetically by path string.</li>
14+
* <li>All component maps (schemas, parameters, requestBodies, responses, headers,
15+
* securitySchemes, examples, links, callbacks) are sorted alphabetically by name.</li>
16+
* <li>HTTP method ordering within a path is handled by {@link org.openapitools.codegen.serializer.PathItemSerializer},
17+
* which writes operations in classical spec order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.</li>
18+
* </ul>
19+
*/
20+
public class OpenAPISorter {
21+
22+
private OpenAPISorter() {
23+
}
24+
25+
public static void sort(OpenAPI openAPI) {
26+
if (openAPI == null) {
27+
return;
28+
}
29+
sortPaths(openAPI);
30+
sortComponents(openAPI);
31+
}
32+
33+
private static void sortPaths(OpenAPI openAPI) {
34+
if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) {
35+
return;
36+
}
37+
Paths sorted = new Paths();
38+
openAPI.getPaths().entrySet().stream()
39+
.sorted(java.util.Map.Entry.comparingByKey())
40+
.forEach(e -> sorted.addPathItem(e.getKey(), e.getValue()));
41+
if (openAPI.getPaths().getExtensions() != null) {
42+
openAPI.getPaths().getExtensions().forEach(sorted::addExtension);
43+
}
44+
openAPI.setPaths(sorted);
45+
}
46+
47+
private static void sortComponents(OpenAPI openAPI) {
48+
Components c = openAPI.getComponents();
49+
if (c == null) {
50+
return;
51+
}
52+
if (c.getSchemas() != null) {
53+
c.setSchemas(new TreeMap<>(c.getSchemas()));
54+
}
55+
if (c.getParameters() != null) {
56+
c.setParameters(new TreeMap<>(c.getParameters()));
57+
}
58+
if (c.getRequestBodies() != null) {
59+
c.setRequestBodies(new TreeMap<>(c.getRequestBodies()));
60+
}
61+
if (c.getResponses() != null) {
62+
c.setResponses(new TreeMap<>(c.getResponses()));
63+
}
64+
if (c.getHeaders() != null) {
65+
c.setHeaders(new TreeMap<>(c.getHeaders()));
66+
}
67+
if (c.getSecuritySchemes() != null) {
68+
c.setSecuritySchemes(new TreeMap<>(c.getSecuritySchemes()));
69+
}
70+
if (c.getExamples() != null) {
71+
c.setExamples(new TreeMap<>(c.getExamples()));
72+
}
73+
if (c.getLinks() != null) {
74+
c.setLinks(new TreeMap<>(c.getLinks()));
75+
}
76+
if (c.getCallbacks() != null) {
77+
c.setCallbacks(new TreeMap<>(c.getCallbacks()));
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)