From c440eb59b47990e1e3cd661ad6a2a187d97ed9f1 Mon Sep 17 00:00:00 2001 From: Ricardo Pinheiro Date: Sun, 5 Jul 2026 11:48:59 +0100 Subject: [PATCH] fix(spring): split response content types Add an opt-in splitResponseTypes option for the spring generator so operations with multiple response content types can generate separate methods without changing the default output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/generators/spring.md | 1 + .../codegen/languages/SpringCodegen.java | 249 +++++++++++++++++- .../java/spring/SpringCodegenTest.java | 79 ++++++ .../test/resources/3_0/spring/issue_8701.yaml | 31 +++ 4 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/issue_8701.yaml diff --git a/docs/generators/spring.md b/docs/generators/spring.md index 1a444e6ef6a6..abb8ef3be6e6 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -97,6 +97,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true| |sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true| |sourceFolder|source folder for generated code| |src/main/java| +|splitResponseTypes|Whether to generate one operation method per response content type when a response declares multiple content types.| |false| |springApiVersion|Value for 'version' attribute in @RequestMapping (for Spring 7 and above).| |null| |substituteGenericPagedModel|Detect schemas that represent paginated responses (an object with a 'content' array property and a 'page' pagination-metadata property) and replace their generated references with PagedModel<T>. By default this uses a generated type in the config package (default 'org.openapitools.configuration'), but `importMappings.PagedModel` can override it to a custom/FQCN-mapped type. The detected page schemas and the pagination metadata schema are suppressed from code generation.| |false| |testOutput|Set output folder for models and APIs tests| |${project.build.directory}/generated-test-sources/openapi| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 2104047f125c..1f93a112b4b1 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -47,6 +47,8 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.net.URL; import java.util.*; import java.util.regex.Matcher; @@ -58,6 +60,7 @@ import static org.openapitools.codegen.CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC; import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER; import static org.openapitools.codegen.utils.StringUtils.camelize; +import static org.openapitools.codegen.utils.StringUtils.underscore; /** *

Mustache templates are located in @@ -78,6 +81,7 @@ public class SpringCodegen extends AbstractJavaCodegen public static final String USE_FEIGN_CLIENT_CONTEXT_ID = "useFeignClientContextId"; public static final String DELEGATE_PATTERN = "delegatePattern"; public static final String SINGLE_CONTENT_TYPES = "singleContentTypes"; + public static final String SPLIT_RESPONSE_TYPES = "splitResponseTypes"; public static final String VIRTUAL_SERVICE = "virtualService"; public static final String SKIP_DEFAULT_INTERFACE = "skipDefaultInterface"; public static final String GENERATE_CONSTRUCTOR_WITH_REQUIRED_ARGS = "generatedConstructorWithRequiredArgs"; @@ -151,6 +155,7 @@ public enum RequestMappingMode { @Setter protected boolean delegatePattern = false; protected boolean delegateMethod = false; @Setter protected boolean singleContentTypes = false; + @Setter protected boolean splitResponseTypes = false; @Setter protected boolean async = false; @Setter protected boolean reactive = false; @Setter protected boolean sse = false; @@ -264,6 +269,9 @@ public SpringCodegen() { "Whether to generate the server files using the delegate pattern", delegatePattern)); cliOptions.add(CliOption.newBoolean(SINGLE_CONTENT_TYPES, "Whether to select only one produces/consumes content-type by operation.", singleContentTypes)); + cliOptions.add(CliOption.newBoolean(SPLIT_RESPONSE_TYPES, + "Whether to generate one operation method per response content type when a response declares multiple content types.", + splitResponseTypes)); cliOptions.add(CliOption.newBoolean(SKIP_DEFAULT_INTERFACE, "Whether to skip generation of default implementations for java8 interfaces", skipDefaultInterface)); cliOptions.add(CliOption.newBoolean(ASYNC, "use async Callable controllers", async)); @@ -520,6 +528,7 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(USE_FEIGN_CLIENT_CONTEXT_ID, this::setUseFeignClientContextId); convertPropertyToBooleanAndWriteBack(DELEGATE_PATTERN, this::setDelegatePattern); convertPropertyToBooleanAndWriteBack(SINGLE_CONTENT_TYPES, this::setSingleContentTypes); + convertPropertyToBooleanAndWriteBack(SPLIT_RESPONSE_TYPES, this::setSplitResponseTypes); convertPropertyToBooleanAndWriteBack(SKIP_DEFAULT_INTERFACE, this::setSkipDefaultInterface); convertPropertyToBooleanAndWriteBack(ASYNC, this::setAsync); if (additionalProperties.containsKey(REACTIVE)) { @@ -979,8 +988,8 @@ public void preprocessOpenAPI(OpenAPI openAPI) { public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { final OperationMap operations = objs.getOperations(); if (operations != null) { - final List ops = operations.getOperation(); - for (final CodegenOperation operation : ops) { + final List originalOps = operations.getOperation(); + for (final CodegenOperation operation : originalOps) { final List responses = operation.responses; if (responses != null) { for (final CodegenResponse resp : responses) { @@ -1024,9 +1033,13 @@ public void setIsVoid(boolean isVoid) { } }); - prepareVersioningParameters(ops); handleImplicitHeaders(operation); } + prepareVersioningParameters(originalOps); + + final List ops = expandOperationsByResponseType(originalOps); + operations.setOperation(ops); + // The tag for the controller is the first tag of the first operation final CodegenOperation firstOperation = ops.get(0); final Tag firstTag = firstOperation.tags.get(0); @@ -1046,6 +1059,236 @@ public void setIsVoid(boolean isVoid) { return objs; } + private List expandOperationsByResponseType(List operations) { + if (!splitResponseTypes) { + return operations; + } + + List expandedOperations = new ArrayList<>(); + for (CodegenOperation operation : operations) { + expandedOperations.addAll(splitOperationByResponseType(operation)); + } + return expandedOperations; + } + + private List splitOperationByResponseType(CodegenOperation operation) { + CodegenResponse methodResponse = findMethodResponse(operation); + if (methodResponse == null || methodResponse.getContent() == null || methodResponse.getContent().size() < 2) { + return Collections.singletonList(operation); + } + + List splitOperations = new ArrayList<>(); + Set usedOperationIds = new HashSet<>(); + for (Map.Entry contentEntry : methodResponse.getContent().entrySet()) { + String mediaType = contentEntry.getKey(); + CodegenOperation splitOperation = copyOperation(operation); + splitOperation.operationId = toSplitOperationId(operation.operationId, mediaType, usedOperationIds); + splitOperation.nickname = splitOperation.operationId; + splitOperation.operationIdLowerCase = splitOperation.operationId.toLowerCase(Locale.ROOT); + splitOperation.operationIdCamelCase = camelize(splitOperation.operationId); + splitOperation.operationIdSnakeCase = underscore(splitOperation.operationId); + splitOperation.produces = copyProduces(operation.produces, mediaType); + splitOperation.hasProduces = splitOperation.produces != null && !splitOperation.produces.isEmpty(); + splitOperation.vendorExtensions = new HashMap<>(operation.vendorExtensions); + splitOperation.vendorExtensions.put("x-accepts", new String[]{mediaType}); + applyResponseMediaType(splitOperation, contentEntry.getValue().getSchema()); + splitOperation.responses = copyResponsesForMediaType(operation.responses, methodResponse, mediaType, contentEntry.getValue()); + splitOperations.add(splitOperation); + } + + return splitOperations; + } + + private CodegenResponse findMethodResponse(CodegenOperation operation) { + return operation.responses.stream() + .filter(response -> response.is2xx) + .min(Comparator.comparing(response -> response.code)) + .orElseGet(() -> operation.responses.stream() + .filter(response -> response.isDefault) + .findFirst() + .orElse(null)); + } + + private String toSplitOperationId(String operationId, String mediaType, Set usedOperationIds) { + String contentType = StringUtils.substringBefore(mediaType, ";"); + String candidate = operationId + camelize(sanitizeName(contentType)); + String uniqueCandidate = candidate; + int counter = 2; + while (!usedOperationIds.add(uniqueCandidate)) { + uniqueCandidate = candidate + counter++; + } + return uniqueCandidate; + } + + private List> copyProduces(List> produces, String mediaType) { + String escapedMediaType = escapeQuotationMark(mediaType); + if (produces != null) { + for (Map produce : produces) { + if (escapedMediaType.equals(produce.get("mediaType"))) { + return Collections.singletonList(new HashMap<>(produce)); + } + } + } + + Map produce = new HashMap<>(); + produce.put("mediaType", escapedMediaType); + if (isJsonMimeType(escapedMediaType)) { + produce.put("isJson", "true"); + } else if (isXmlMimeType(escapedMediaType)) { + produce.put("isXml", "true"); + } + return Collections.singletonList(produce); + } + + private void applyResponseMediaType(CodegenOperation operation, CodegenProperty schema) { + operation.returnProperty = schema; + if (schema == null) { + operation.returnType = null; + operation.returnBaseType = null; + operation.returnContainer = null; + operation.isVoid = true; + operation.isArray = false; + operation.isMap = false; + operation.isResponseBinary = false; + operation.isResponseFile = false; + operation.returnTypeIsPrimitive = false; + operation.returnSimpleType = true; + operation.hasReference = false; + return; + } + + operation.returnType = schema.getDataType(); + operation.returnBaseType = resolveBaseType(schema); + operation.returnContainer = null; + operation.isVoid = schema.isVoid; + operation.isArray = schema.isArray; + operation.isMap = schema.isMap; + operation.isResponseBinary = schema.isBinary; + operation.isResponseFile = schema.isFile || schema.isBinary; + operation.returnTypeIsPrimitive = schema.isPrimitiveType; + operation.returnSimpleType = !schema.isArray && !schema.isMap && !schema.isModel; + operation.hasReference = schema.isModel || schema.getComplexType() != null; + + doDataTypeAssignment(operation.returnType, new DataTypeAssigner() { + @Override + public void setReturnType(String returnType) { + operation.returnType = returnType; + } + + @Override + public void setReturnContainer(String returnContainer) { + operation.returnContainer = returnContainer; + } + + @Override + public void setIsVoid(boolean isVoid) { + operation.isVoid = isVoid; + } + }); + } + + private List copyResponsesForMediaType(List responses, + CodegenResponse methodResponse, + String mediaType, + CodegenMediaType contentType) { + List copiedResponses = new ArrayList<>(responses.size()); + for (CodegenResponse response : responses) { + if (response == methodResponse) { + CodegenResponse copiedResponse = copyResponse(response); + LinkedHashMap singleContentType = new LinkedHashMap<>(); + singleContentType.put(mediaType, contentType); + copiedResponse.setContent(singleContentType); + applyResponseContentType(copiedResponse, contentType.getSchema()); + copiedResponses.add(copiedResponse); + } else { + copiedResponses.add(response); + } + } + return copiedResponses; + } + + private void applyResponseContentType(CodegenResponse response, CodegenProperty schema) { + response.returnProperty = schema; + if (schema == null) { + response.dataType = null; + response.baseType = null; + response.containerType = null; + response.isArray = false; + response.isMap = false; + response.isBinary = false; + response.isFile = false; + response.isVoid = true; + response.isModel = false; + return; + } + + response.dataType = schema.getDataType(); + response.baseType = resolveBaseType(schema); + response.containerType = schema.getContainerType(); + response.isArray = schema.isArray; + response.isMap = schema.isMap; + response.isBinary = schema.isBinary; + response.isFile = schema.isFile || schema.isBinary; + response.isVoid = schema.isVoid; + response.isModel = schema.isModel; + } + + private String resolveBaseType(CodegenProperty schema) { + if (schema == null) { + return null; + } + if (schema.isArray) { + String baseType = schema.getBaseType(); + CodegenProperty innerProperty = schema.items; + while (innerProperty != null) { + baseType = innerProperty.getComplexType() != null ? innerProperty.getComplexType() : innerProperty.getBaseType(); + innerProperty = innerProperty.items; + } + return baseType; + } + if (schema.isMap && schema.additionalProperties != null) { + return schema.additionalProperties.getComplexType() != null + ? schema.additionalProperties.getComplexType() + : schema.additionalProperties.getBaseType(); + } + if (schema.getComplexType() != null) { + if (schema.items != null && schema.items.getComplexType() != null) { + return schema.items.getComplexType(); + } + return schema.getComplexType(); + } + return schema.getBaseType(); + } + + private CodegenOperation copyOperation(CodegenOperation source) { + CodegenOperation target = new CodegenOperation(); + copyPublicFields(CodegenOperation.class, source, target); + target.responseHeaders.addAll(source.responseHeaders); + return target; + } + + private CodegenResponse copyResponse(CodegenResponse source) { + CodegenResponse target = new CodegenResponse(); + copyPublicFields(CodegenResponse.class, source, target); + target.headers.addAll(source.headers); + target.setResponseHeaders(new ArrayList<>(source.getResponseHeaders())); + target.setContent(source.getContent() == null ? null : new LinkedHashMap<>(source.getContent())); + return target; + } + + private void copyPublicFields(Class type, Object source, Object target) { + try { + for (Field field : type.getFields()) { + if (Modifier.isStatic(field.getModifiers()) || Modifier.isFinal(field.getModifiers())) { + continue; + } + field.set(target, field.get(source)); + } + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to copy " + type.getSimpleName(), e); + } + } + private interface DataTypeAssigner { void setReturnType(String returnType); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index e7a4f02f7f0b..4b8291f132d0 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -80,6 +80,85 @@ public void clientOptsUnicity() { .forEach((k, v) -> assertEquals(v.size(), 1, k + " is described multiple times")); } + @Test + public void shouldKeepSingleMethodByDefaultForMultipleResponseContentTypes() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + OpenAPI openAPI = new OpenAPIParser() + .readLocation("src/test/resources/3_0/spring/issue_8701.yaml", null, new ParseOptions()).getOpenAPI(); + + SpringCodegen codegen = new SpringCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_TAGS, "true"); + + ClientOptInput input = new ClientOptInput(); + input.openAPI(openAPI); + input.config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); + + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(files.get("FooApi.java").toPath()) + .assertMethod("getFoo") + .hasReturnType("ResponseEntity") + .assertMethodAnnotations() + .containsWithNameAndAttributes("RequestMapping", ImmutableMap.of( + "produces", "{ \"application/json\", \"text/plain\", \"application/octet-stream\" }")) + .toMethod() + .toFileAssert() + .hasNoMethod("getFooApplicationJson") + .hasNoMethod("getFooTextPlain") + .hasNoMethod("getFooApplicationOctetStream"); + } + + @Test + public void shouldSplitOperationsByResponseContentTypeWhenEnabled() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + OpenAPI openAPI = new OpenAPIParser() + .readLocation("src/test/resources/3_0/spring/issue_8701.yaml", null, new ParseOptions()).getOpenAPI(); + + SpringCodegen codegen = new SpringCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_TAGS, "true"); + codegen.additionalProperties().put(SPLIT_RESPONSE_TYPES, true); + + ClientOptInput input = new ClientOptInput(); + input.openAPI(openAPI); + input.config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); + + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + Path fooApi = files.get("FooApi.java").toPath(); + JavaFileAssert.assertThat(fooApi) + .hasNoMethod("getFoo") + .assertMethod("getFooApplicationJson") + .hasReturnType("ResponseEntity") + .assertMethodAnnotations() + .containsWithNameAndAttributes("RequestMapping", ImmutableMap.of("produces", "{ \"application/json\" }")) + .toMethod() + .toFileAssert() + .assertMethod("getFooTextPlain") + .hasReturnType("ResponseEntity") + .assertMethodAnnotations() + .containsWithNameAndAttributes("RequestMapping", ImmutableMap.of("produces", "{ \"text/plain\" }")); + + assertFileContains(fooApi, + "ResponseEntity getFooApplicationOctetStream", + "produces = { \"application/octet-stream\" }"); + } + @Test public void doAnnotateDatesOnModelParameters() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/issue_8701.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/issue_8701.yaml new file mode 100644 index 000000000000..6949b5bc9b8a --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/issue_8701.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.3 +info: + title: Split response content types + version: "1.0.0" +paths: + /foo: + get: + tags: + - foo + operationId: getFoo + responses: + "200": + description: Return + content: + application/json: + schema: + $ref: "#/components/schemas/FooDto" + text/plain: + schema: + type: string + application/octet-stream: + schema: + type: string + format: binary +components: + schemas: + FooDto: + type: object + properties: + name: + type: string