[JAVA-SPRING] Convert byte[] to String for operation params [QueryParam; PathParam; HeaderParam; CookieParam; FormParam]

Draft
PI
1mo ago
+5132-214

Correctly mapping OpenAPI format: byte for the Java Spring generator

Fixes: #22898
Scope: SpringCodegen.java (Java client/server) — spring-boot, spring-cloud (Feign), spring-http-interface libraries. kotlin-spring is not covered (see "Known gaps" below).


1. The problem

The OpenAPI Specification defines type: string, format: byte as "base64 encoded characters" — i.e. the wire value is always a base64 string, and it is the application's job to decode/encode it to actual bytes:

byte – base64 encoded characters
— OpenAPI/Swagger data types reference

Before this fix, the Java Spring generator mapped format: byte to byte[] everywhere a byte-format value could appear: in the JSON body, and also in query, path, header, cookie, and form parameters. That mapping is only actually correct for one of those locations.

Why byte[] works for a JSON request/response body

When format: byte appears on a DTO field serialized by Jackson (the JSON body case), Jackson's ObjectMapper automatically base64-encodes/decodes byte[] fields using its default Base64Variant (MIME_NO_LINEFEEDS). This is standard, well-documented Jackson behavior — see the Jackson databind Javadoc for binary/byte[] handling and Base64Variants. So byte[] in a JSON body is genuinely correct and needs no change — the framework does the base64 work for you.

Why byte[] is wrong for query/path/header/cookie/form params

Query, path, header, cookie, and form-urlencoded parameters are not deserialized by Jackson at all. Spring MVC/WebFlux binds these using its general-purpose ConversionService (@RequestParam, @PathVariable, @RequestHeader, @CookieValue — see Spring's type conversion documentation and the @RequestParam reference).

I decompiled spring-core-6.2.18.jar to see exactly what the default ConversionService does with a byte[] target/source, and confirmed the registered converter is org.springframework.core.convert.support.ArrayToStringConverter (and its inverse, StringToArrayConverter). Its logic is:

ArrayToStringConverter.convert(byte[] source, ...)
  → ObjectUtils.toObjectArray(source)      // boxes each byte to Byte
  → Arrays.asList(...)
  → CollectionToStringConverter            // joins elements with "," using Object#toString()

So a byte array such as {104, 101, 108, 108, 111} ("hello") is not base64-encoded by this path — it is rendered as the literal string "104,101,108,108,111" (decimal byte values joined by commas). The reverse conversion (StringToArrayConverter, incoming request → controller method param) expects that same comma-separated decimal format, not base64.

Consequence of the old behavior: any client sending a real base64 string (as the OpenAPI spec requires) into a query/path/header/cookie/form parameter typed byte[] would fail to bind correctly (Spring would try to parse the base64 string as comma-separated integers and typically throw a conversion exception), and any server emitting a byte[] into one of these locations would not actually produce valid base64 on the wire. The byte[] type was misleading — it implied "correctly typed bytes," but no component in the request pipeline for these locations understood base64.


2. The fix

2.1 convertByteArrayParamsToStringType(operation)

For every parameter on an operation whose location is not the request body (queryParams, pathParams, headerParams, cookieParams, formParams) and whose OpenAPI type is string, format: byte, the generator now rewrites the generated Java dataType from byte[] to String. A short code comment (/* base64 encoded binary */) is emitted next to the parameter in the generated method signature so the base64 contract is visible to anyone reading the generated code.

This means:

  • Consumers of a generated client (Feign, spring-http-interface, RestTemplate/WebClient-based clients) must now pass an already-base64-encoded String, e.g. Base64.getEncoder().encodeToString(bytes), instead of a raw byte[].
  • Implementers of a generated server controller receive the base64 String as-is and must decode it themselves, e.g. Base64.getDecoder().decode(value), when they need the actual bytes.

This is intentionally symmetrical with what a hand-written Spring application would have to do anyway, since Spring's ConversionService was never going to do this for you correctly.

2.2 markMultipartFormDataParameters(operation) (reactive/WebFlux only)

Spring WebFlux does not support the same MVC-style binding for multipart/form-data text fields — WebFlux typically needs @RequestPart (bound to a Part/Mono<Part>) for all parts of a multipart request, including plain text fields, whereas Spring MVC can bind simple text fields with a plain @RequestParam. This method flags each non-file multipart field with the vendor extension x-isMultipartFormData so the mustache templates can:

  • emit @RequestPart (not @RequestParam) for reactive text fields, and
  • type them as String (or Flux<String>/List<String> for repeated/array fields) rather than the raw model type, consistent with how WebFlux exposes multipart text parts.

File parts (format: binary) are unaffected by either method and keep the framework's native multipart abstraction — MultipartFile for Spring MVC, Part/FilePart for WebFlux — since these already stream raw bytes correctly with no base64 involved.

2.3 Summary of the resulting type mapping

LocationOpenAPI typeGenerated Java typeEncoding handled by
Query / Path / Header / Cookie / Formstring, format: byteStringManual (caller/implementer does base64 encode/decode)
Multipart text fieldstring, format: byte or plain stringStringManual, same as above
Multipart file fieldstring, format: binaryMultipartFile (MVC) / Part (WebFlux)None needed — raw bytes stream through the multipart part
JSON request/response body fieldstring, format: bytebyte[]Automatic — Jackson base64-encodes/decodes by default
Raw application/octet-stream bodystring, format: binaryResourceNone needed — streamed, no decoding

This preserves the important distinction the OpenAPI spec makes between format: byte (base64 text) and format: binary (raw bytes), rather than collapsing both into byte[] regardless of transport location.


3. Why this is correct (not just "different")

  1. The old byte[] typing for params was already broken, not merely stylistic. See the decompiled ArrayToStringConverter behavior in §1 — Spring's default conversion never implemented base64 for these locations, so a byte[] in a query/path/header/cookie/form parameter round-tripped through decimal-CSV, not base64. This is confirmed by direct bytecode inspection of spring-core, not conjecture.
  2. This mirrors the framework's own type boundary. Spring's Jackson-backed HttpMessageConverter path (JSON body) is the only place in the stack that base64-encodes byte[] automatically. Everywhere else, Spring intentionally leaves conversion to whatever Converter<S,T> is registered, and none is registered for base64. Typing the generated parameter as String accurately reflects this framework boundary instead of hiding it behind a byte[] that silently didn't work.
  3. It is a breaking, but necessary, change to generated client method signatures. Any hand-written caller previously passing a byte[] to a generated Feign/http-interface/WebClient method for one of these locations must now pass a base64 String. This is source-incompatible, but the previous behavior was not actually functioning end-to-end, so there is no working call pattern being broken — only incorrect code that either never compiled against real usage or silently sent corrupt data.

4. Verification performed (this session)

4.1 Unit / codegen tests

  • SpringCodegenTest.shouldHandleFormatByteCorrectlyForAllApiParametersAndProperties (drives byte-format-baseline.yaml) — asserts the exact type mapping table in §2.3 for every param location and for JSON/binary bodies.
  • SpringCodegenTest.testMultipartBoot / testReactiveMultipartBoot, JavaClientCodegenTest multipart tests, KotlinSpringServerCodegenTest — all passing after the master merge (953 targeted tests, 0 failures).

4.2 Real generated sample projects (compiled + integration-tested)

  • samples/server/petstore/springboot-byte-format-edge-cases (Spring MVC, blocking) — 21 integration tests (MockMvc), 0 failures.
  • samples/server/petstore/springboot-byte-format-edge-cases-reactive (WebFlux) — 22 integration tests (WebTestClient), 0 failures.
  • Both projects have a real hand-written controller implementation (CoverageApiTestControllerImpl) that base64-decodes incoming values and compares against known expected byte content, so the tests validate actual data correctness, not just type-compatibility.

4.3 Live end-to-end curl verification (both MVC and WebFlux servers actually running)

Ran both sample apps live (mvn spring-boot:run, blocking on Tomcat and reactive on Netty) and exercised every location by hand:

  • Query, path, header, cookie, form params: valid base64 → 204; base64 of the wrong content → 500 (proves the server genuinely decodes and content-checks, not just accepts any string).
  • Multipart simple (file + base64 text field) and multipart mixed (including repeated statusArray via @RequestParam and JSON markerArray via @RequestPart) → 204 for both blocking and reactive.
  • Raw application/octet-stream body: correct bytes → 204; wrong bytes → 500.
  • Results were identical between blocking and reactive modes, confirming the WebFlux-specific @RequestPart/Flux<String> handling in markMultipartFormDataParameters works correctly, not just compiles.

4.4 Client-side libraries (Feign / spring-http-interface)

Regenerated byte-format-baseline.yaml with --library spring-cloud and --library spring-http-interface via the built CLI jar:

  • Confirmed byte-format params (query/path/header/cookie/form) are generated as String with the base64 comment, exactly as in the spring-boot server case.
  • Confirmed multipart file parameters remain MultipartFile with @RequestPart, and text fields become @RequestParam String.
  • Both generated clients compiled cleanly (mvn compile, zero errors) — the change does not break Feign contract parsing or the Spring 6 @HttpExchange-based declarative HTTP interface generation. Both share the exact same SpringCodegen.postProcessOperationsWithModels() code path as spring-boot, so there is no separate logic path that could diverge.

4.5 Known gap identified (not fixed by this branch)

kotlin-spring uses a separate codegen class, KotlinSpringServerCodegen (extends AbstractKotlinCodegen, not SpringCodegen), with its own template set. Regenerating the same spec with -g kotlin-spring confirmed the query parameter is still generated as bytes: kotlin.ByteArray? — i.e. the same underlying bug (§1) still exists there. Porting this fix to Kotlin would be a separate, follow-up change; it is out of scope for this PR/branch.


5. References

PR checklist

  • Read the contribution guidelines.
  • Pull Request title clearly describes the work in the pull request and Pull Request description provides details about how to validate the work. Missing information here may result in delayed response from the community.
  • Run the following to build the project and update samples:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    (For Windows users, please run the script in WSL)
    Commit all changed files.
    This is important, as CI jobs will verify all generator outputs of your HEAD commit as it would merge with master.
    These must match the expectations made by your contribution.
    You may regenerate an individual generator by passing the relevant config(s) as an argument to the script, for example ./bin/generate-samples.sh bin/configs/java*.
    IMPORTANT: Do NOT purge/delete any folders/files (e.g. tests) when regenerating the samples as manually written tests may be removed.
  • File the PR against the correct branch: master (upcoming 7.x.0 minor release - breaking changes with fallbacks), 8.0.x (breaking changes without fallbacks)
  • If your PR solves a reported issue, reference it using GitHub's linking syntax (e.g., having "fixes #123" present in the PR description)
  • If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request. - tagging: @cachescrubber (2022/02) @welshm (2022/02) @MelleD (2022/02) @atextor (2022/02) @manedev79 (2022/02) @javisst (2022/02) @borsch (2022/02) @Zomzog (2022/09) @martin-mfg (2023/08)

Summary by cubic

Converts byte[] operation parameters to String in the Java Spring generator for query, path, header, cookie, and form to fix base64 handling and align generated types with the OpenAPI spec. Also fixes multipart binding edge cases and adds MVC/WebFlux samples and tests to cover them.

Bug Fixes

  • Map byte and array-of-byte params to String/List<String> in SpringCodegen; add the base64 comment via lambdaTypeComment and a "(base64 encoded)" note in the shared @param Javadoc.
  • Preserve types elsewhere: byte[] in models and JSON bodies, Resource for application/octet-stream, MultipartFile/Part for multipart files; reactive non-file form params now use @RequestParam.
  • Fix mixed multipart arrays and the multipart media-type check in Java Spring, adding CoverageApi endpoints and new MVC/WebFlux edge-case samples with CI generation.
  • RFC3339DateFormat forces UTC, uses a per-call StdDateFormat, and its clone() returns a proper copy; reactive sample controllers close request streams and release pooled DataBuffer instances.
  • Isolate the new array and mixed-case multipart coverage in a dedicated form-multipart-binary-array-extended.yaml spec so the shared fixture and C# restsharp client remain unchanged.

Migration

  • If you previously used byte[] for these params, decode the provided base64 String yourself (e.g., Base64.getDecoder().decode(value)).