Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
59bd0eb
convert byte[] to String for operation params
Picazsoo Feb 9, 2026
78499b6
add unit tests
Picazsoo Feb 9, 2026
02a530a
clean up open api spec
Picazsoo Feb 9, 2026
8685cb9
fix CR suggestions
Picazsoo Feb 10, 2026
e78800b
add sample
Picazsoo Feb 10, 2026
6e37d4e
add sample and fix log
Picazsoo Feb 10, 2026
e0d2d56
up-to-date
Picazsoo Feb 10, 2026
4311007
remove typeMappping
Picazsoo Feb 10, 2026
2e92477
fix implementation and tests
Picazsoo Feb 11, 2026
49a9869
implement CR feedback
Picazsoo Feb 11, 2026
1dc3a5c
fix test
Picazsoo Feb 11, 2026
a80c790
remove accidental change in kotlin tests
Picazsoo Feb 11, 2026
e1a47cb
remove extraneous file from kotlin open api specs
Picazsoo Feb 11, 2026
8abc255
update samples
Picazsoo Feb 11, 2026
08eee7f
update samples
Picazsoo Feb 11, 2026
69ebc07
Merge branch 'master' into feature/java-spring-convert-byte-array-ope…
Picazsoo Feb 11, 2026
2a4b8f2
update samples after merge of master
Picazsoo Feb 11, 2026
fd81045
update samples after merge of master
Picazsoo Feb 11, 2026
e6faab7
revert unrelated changes
Picazsoo Feb 11, 2026
c899cb4
fix reactive multipart
Picazsoo Feb 12, 2026
6edb518
update samples
Picazsoo Feb 12, 2026
ef44359
add lambda to add the type comment conditionally.
Picazsoo Feb 12, 2026
72337a5
delete old sample files
Picazsoo Feb 12, 2026
281fa2a
fix test
Picazsoo Feb 12, 2026
09bb69c
update samples
Picazsoo Feb 12, 2026
60780a1
add api endpoint for tests and update samples
Picazsoo Feb 12, 2026
879439e
update samples
Picazsoo Feb 12, 2026
6170b60
update samples and add tests
Picazsoo Feb 12, 2026
20a0ae2
fix test name
Picazsoo Feb 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/samples-spring.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:
- samples/server/petstore/spring-boot-nullable-set
- samples/server/petstore/spring-boot-defaultInterface-unhandledExcp
- samples/server/petstore/springboot
- samples/server/petstore/springboot-byte-format-edge-cases
- samples/server/petstore/springboot-byte-format-edge-cases-reactive
- samples/server/petstore/springboot-beanvalidation
- samples/server/petstore/springboot-builtin-validation
- samples/server/petstore/springboot-delegate
Expand Down
11 changes: 11 additions & 0 deletions bin/configs/spring-boot-byte-format-edge-cases-reactive.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
generatorName: spring
outputDir: samples/server/petstore/springboot-byte-format-edge-cases-reactive
inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/byte-format-edge-cases.yaml
templateDir: modules/openapi-generator/src/main/resources/JavaSpring
additionalProperties:
artifactId: springboot
snapshotVersion: "true"
hideGenerationTimestamp: "true"
reactive: true
interfaceOnly: "true"
requestMappingMode: "none"
10 changes: 10 additions & 0 deletions bin/configs/spring-boot-byte-format-edge-cases.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
generatorName: spring
outputDir: samples/server/petstore/springboot-byte-format-edge-cases
inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/byte-format-edge-cases.yaml
templateDir: modules/openapi-generator/src/main/resources/JavaSpring
additionalProperties:
artifactId: springboot
snapshotVersion: "true"
hideGenerationTimestamp: "true"
interfaceOnly: "true"
requestMappingMode: "none"
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,27 @@ public void processOpts() {

additionalProperties.put("lambdaSplitString", new SplitStringLambda());

// Lambda to add type comment only if actual type differs from implemented type
// Format: "implementedType|actualType" -> " /* actualType */" or ""
additionalProperties.put("lambdaTypeComment", (Mustache.Lambda) (fragment, writer) -> {
String input = fragment.execute().trim();
String[] parts = input.split("\\|", 2);
if (parts.length == 2) {
String implementedType = parts[0].trim();
String actualType = parts[1].trim();

// Only add comment if types differ
if (!implementedType.equals(actualType)) {
// Also check if implementedType is a collection of actualType (e.g., List<String> vs String)
String expectedCollection = "List<" + actualType + ">";
String expectedFlux = "Flux<" + actualType + ">";
if (!implementedType.equals(expectedCollection) && !implementedType.equals(expectedFlux)) {
writer.write(" /* " + actualType + " */");
}
}
}
});

// apiController: hide implementation behind undocumented flag to temporarily preserve code
additionalProperties.put("_api_controller_impl_", false);
// HEADS-UP: Do not add more template file after this block
Expand Down Expand Up @@ -798,6 +819,8 @@ public void setIsVoid(boolean isVoid) {

prepareVersioningParameters(ops);
handleImplicitHeaders(operation);
convertByteArrayParamsToStringType(operation);
markMultipartFormDataParameters(operation);
}
// The tag for the controller is the first tag of the first operation
final CodegenOperation firstOperation = ops.get(0);
Expand All @@ -813,6 +836,64 @@ public void setIsVoid(boolean isVoid) {
return objs;
}

/**
* Converts parameters of type {@code byte[]} (i.e., OpenAPI {@code type: string, format: byte}) to {@code String}.
* <p>
* In OpenAPI, {@code type: string, format: byte} is a base64-encoded string. However, Spring does not automatically
* decode base64-encoded request parameters into {@code byte[]} for query, path, header, cookie, or form parameters.
* Therefore, these parameters are mapped to {@code String} to avoid incorrect type handling and to ensure the
* application receives the raw base64 string as provided by the client.
* </p>
*
* @param operation the codegen operation whose parameters will be checked and converted if necessary
**/
private void convertByteArrayParamsToStringType(CodegenOperation operation) {
var convertedParams = operation.allParams.stream()
.filter(CodegenParameter::getIsByteArray)
.filter(param -> param.isQueryParam || param.isPathParam || param.isHeaderParam || param.isCookieParam || param.isFormParam)
.peek(param -> param.dataType = "String")
.collect(Collectors.toList());
LOGGER.info("Converted parameters [{}] from byte[] to String in operation [{}]", convertedParams.stream().map(param -> param.paramName).collect(Collectors.toList()), operation.operationId);
}

/**
* Marks form parameters that are in multipart/form-data operations for special handling in reactive mode.
* <p>
* In reactive Spring WebFlux, multipart/form-data parameters must use @RequestPart instead of @RequestParam,
* and non-model parameters (primitives, enums, strings) must be received as String or Flux&lt;String&gt; and
* converted manually in the implementation.
* </p>
*
* @param operation the codegen operation whose parameters will be marked if necessary
**/
private void markMultipartFormDataParameters(CodegenOperation operation) {
if (!reactive) {
return; // Only applies to reactive mode
}

// Check if this operation consumes multipart/form-data
boolean isMultipartFormData = false;
if (operation.hasConsumes) {
for (Map<String, String> consume : operation.consumes) {
if ("multipart/form-data".equals(consume.get("mediaType"))) {
isMultipartFormData = true;
break;
}
}
}

if (!isMultipartFormData) {
return;
}

// Mark all form parameters as multipart form data
for (CodegenParameter param : operation.allParams) {
if (param.isFormParam) {
param.vendorExtensions.put("x-isMultipartFormData", true);
}
}
}

private interface DataTypeAssigner {
void setReturnType(String returnType);

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#isCookieParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}} @CookieValue(name = "{{baseName}}"{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{>dateTimeParam}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{/isCookieParam}}
{{#isCookieParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}} @CookieValue(name = "{{baseName}}"{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{>dateTimeParam}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{#isByteArray}} /* base64 encoded binary */{{/isByteArray}}{{/isCookieParam}}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#isFormParam}}{{^isFile}}{{>paramDoc}}{{#useBeanValidation}} {{>beanValidationBodyParams}}@Valid{{/useBeanValidation}} {{#isModel}}@RequestPart{{/isModel}}{{^isModel}}{{#isArray}}@RequestPart{{/isArray}}{{^isArray}}{{#reactive}}@RequestPart{{/reactive}}{{^reactive}}@RequestParam{{/reactive}}{{/isArray}}{{/isModel}}(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}){{>dateTimeParam}} {{^required}}{{#useOptional}}Optional<{{/useOptional}}{{/required}}{{{dataType}}}{{^required}}{{#useOptional}}>{{/useOptional}}{{/required}} {{paramName}}{{/isFile}}{{#isFile}}{{>paramDoc}} @RequestPart(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}) {{#reactive}}{{#isArray}}Flux<{{/isArray}}Part{{#isArray}}>{{/isArray}}{{/reactive}}{{^reactive}}{{#isArray}}List<{{/isArray}}MultipartFile{{#isArray}}>{{/isArray}}{{/reactive}} {{paramName}}{{/isFile}}{{/isFormParam}}
{{#isFormParam}}{{^isFile}}{{>paramDoc}}{{#useBeanValidation}} {{>beanValidationBodyParams}}@Valid{{/useBeanValidation}} {{#isModel}}@RequestPart{{/isModel}}{{^isModel}}{{#isArray}}{{#items.isModel}}@RequestPart{{/items.isModel}}{{^items.isModel}}{{#reactive}}{{#vendorExtensions.x-isMultipartFormData}}@RequestPart{{/vendorExtensions.x-isMultipartFormData}}{{^vendorExtensions.x-isMultipartFormData}}@RequestParam{{/vendorExtensions.x-isMultipartFormData}}{{/reactive}}{{^reactive}}@RequestParam{{/reactive}}{{/items.isModel}}{{/isArray}}{{^isArray}}{{#reactive}}{{#vendorExtensions.x-isMultipartFormData}}@RequestPart{{/vendorExtensions.x-isMultipartFormData}}{{^vendorExtensions.x-isMultipartFormData}}@RequestParam{{/vendorExtensions.x-isMultipartFormData}}{{/reactive}}{{^reactive}}@RequestParam{{/reactive}}{{/isArray}}{{/isModel}}(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}){{>dateTimeParam}} {{^required}}{{#useOptional}}Optional<{{/useOptional}}{{/required}}{{#reactive}}{{#vendorExtensions.x-isMultipartFormData}}{{#isModel}}{{{dataType}}}{{/isModel}}{{^isModel}}{{#isArray}}{{#items.isModel}}{{{dataType}}}{{/items.isModel}}{{^items.isModel}}Flux<String>{{#lambdaTypeComment}}Flux<String>|{{{dataType}}}{{/lambdaTypeComment}}{{/items.isModel}}{{/isArray}}{{^isArray}}String{{#lambdaTypeComment}}String|{{{dataType}}}{{/lambdaTypeComment}}{{/isArray}}{{/isModel}}{{/vendorExtensions.x-isMultipartFormData}}{{^vendorExtensions.x-isMultipartFormData}}{{{dataType}}}{{/vendorExtensions.x-isMultipartFormData}}{{/reactive}}{{^reactive}}{{{dataType}}}{{/reactive}}{{^required}}{{#useOptional}}>{{/useOptional}}{{/required}} {{paramName}}{{/isFile}}{{#isByteArray}} /* base64 encoded binary */{{/isByteArray}}{{#isFile}}{{>paramDoc}} @RequestPart(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}) {{#reactive}}{{#isArray}}Flux<{{/isArray}}Part{{#isArray}}>{{/isArray}}{{/reactive}}{{^reactive}}{{#isArray}}List<{{/isArray}}MultipartFile{{#isArray}}>{{/isArray}}{{/reactive}} {{paramName}}{{/isFile}}{{/isFormParam}}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#isHeaderParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}} @RequestHeader(value = "{{baseName}}", required = {{#required}}true{{/required}}{{^required}}false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{>dateTimeParam}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{/isHeaderParam}}
{{#isHeaderParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}} @RequestHeader(value = "{{baseName}}", required = {{#required}}true{{/required}}{{^required}}false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{>dateTimeParam}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{#isByteArray}} /* base64 encoded binary */{{/isByteArray}}{{/isHeaderParam}}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#isPathParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationPathParams}}{{/useBeanValidation}}{{>paramDoc}} @PathVariable("{{baseName}}"){{>dateTimeParam}}{{#isDeprecated}} @Deprecated{{/isDeprecated}} {{>optionalDataType}} {{paramName}}{{/isPathParam}}
{{#isPathParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationPathParams}}{{/useBeanValidation}}{{>paramDoc}} @PathVariable("{{baseName}}"){{>dateTimeParam}}{{#isDeprecated}} @Deprecated{{/isDeprecated}} {{>optionalDataType}} {{paramName}}{{#isByteArray}} /* base64 encoded binary */{{/isByteArray}}{{/isPathParam}}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#isQueryParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}}{{#useBeanValidation}} @Valid{{/useBeanValidation}}{{^isModel}} @RequestParam(value = {{#isMap}}""{{/isMap}}{{^isMap}}"{{baseName}}"{{/isMap}}{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{/isModel}}{{>dateTimeParam}}{{#isDeprecated}} @Deprecated{{/isDeprecated}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{/isQueryParam}}
{{#isQueryParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}}{{#useBeanValidation}} @Valid{{/useBeanValidation}}{{^isModel}} @RequestParam(value = {{#isMap}}""{{/isMap}}{{^isMap}}"{{baseName}}"{{/isMap}}{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{/isModel}}{{>dateTimeParam}}{{#isDeprecated}} @Deprecated{{/isDeprecated}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{#isByteArray}} /* base64 encoded binary */{{/isByteArray}}{{/isQueryParam}}
Original file line number Diff line number Diff line change
Expand Up @@ -2690,7 +2690,7 @@ public void testRestClientFormMultipart() {
+ " files.stream().map(FileSystemResource::new).collect(Collectors.toList()));",

// mixed
"multipartMixed(@jakarta.annotation.Nonnull MultipartMixedStatus status, @jakarta.annotation.Nonnull File _file, @jakarta.annotation.Nullable MultipartMixedRequestMarker marker, @jakarta.annotation.Nullable List<MultipartMixedStatus> statusArray)",
"multipartMixed(@jakarta.annotation.Nonnull MultipartMixedStatus status, @jakarta.annotation.Nonnull File _file, @jakarta.annotation.Nullable MultipartMixedRequestMarker marker, @jakarta.annotation.Nullable List<MultipartMixedRequestMarker> markerArray, @jakarta.annotation.Nullable List<MultipartMixedStatus> statusArray)",
"formParams.add(\"file\", new FileSystemResource(_file));",

// single file
Expand Down Expand Up @@ -2720,7 +2720,7 @@ public void testRestClientWithUseAbstractionForFiles() {
"formParams.addAll(\"files\", files.stream().collect(Collectors.toList()));",

// mixed
"multipartMixed(@jakarta.annotation.Nonnull MultipartMixedStatus status, org.springframework.core.io.AbstractResource _file, @jakarta.annotation.Nullable MultipartMixedRequestMarker marker, @jakarta.annotation.Nullable List<MultipartMixedStatus> statusArray)",
"multipartMixed(@jakarta.annotation.Nonnull MultipartMixedStatus status, org.springframework.core.io.AbstractResource _file, @jakarta.annotation.Nullable MultipartMixedRequestMarker marker, @jakarta.annotation.Nullable List<MultipartMixedRequestMarker> markerArray, @jakarta.annotation.Nullable List<MultipartMixedStatus> statusArray)",
"formParams.add(\"file\", _file);",

// single file
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ public PropertyAssert withType(final String expectedType) {
return this;
}

public PropertyAssert isArray() {
Assertions.assertThat(actual.getCommonType().isArrayType())
.withFailMessage("Expected property %s to be array, but it was NOT", actual.getVariable(0).getNameAsString())
.isEqualTo(true);
return this;
}

public PropertyAssert isNotArray() {
Assertions.assertThat(actual.getCommonType().isArrayType())
.withFailMessage("Expected property %s NOT to be array, but it was", actual.getVariable(0).getNameAsString())
.isEqualTo(false);
return this;
}

public PropertyAnnotationsAssert assertPropertyAnnotations() {
return new PropertyAnnotationsAssert(this, actual.getAnnotations());
}
Expand Down
Loading
Loading