Skip to content

feat: create streamable multipart body encoder#3414

Merged
velo merged 2 commits into
OpenFeign:14.xfrom
yvasyliev:feature/multipart-form-streaming
Jun 15, 2026
Merged

feat: create streamable multipart body encoder#3414
velo merged 2 commits into
OpenFeign:14.xfrom
yvasyliev:feature/multipart-form-streaming

Conversation

@yvasyliev

@yvasyliev yvasyliev commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

The third PR in a series related to #2734.

Introduces feign.form.MultipartFormEncoder — a new, fully streamable encoder for multipart/form-data requests. Unlike the existing FormEncoder, it writes each part directly to the underlying OutputStream without buffering the whole payload in memory, making it practical for large file uploads.

This is an additive, non-breaking change. Existing FormEncoder users are not affected. See MIGRATION-v14.md for notes.

Note

For maintainers: existing FormEncoder (non-streaming) coexists in the same package as new MultipartFormEncoder (streaming). While I tried to add explicit notes to README.md, new users may get confused by these two encoders as well as feign.form.FormData (existing) vs feign.form.multipart.FormData (new) container classes. I decided not to remove any existing encoders so that this PR (a) becomes non-breaking and (b) does not introduce UrlencodedFormEncoder which is out of #2734 scope.
If you prefer to remove the existing FormEncoder so that the users don't get confused, then this PR will become breaking. The same goes to feign.form.spring.SpringFormEncoder & feign.form.spring.MultipartFileEncoder.


What's new

feign.form.MultipartFormEncoder

The top-level encoder. It intercepts requests whose Content-Type is multipart/form-data and serialises all parameters as a streamed multipart body. Everything else is forwarded to a configurable delegate encoder (DefaultEncoder by default).

Constructor / builder options:

API Purpose
new MultipartFormEncoder() Sensible defaults; use as a drop-in
new MultipartFormEncoder(Encoder delegate) Custom delegate for non-multipart bodies
MultipartFormEncoder.builder() Full control: custom part resolvers, part encoders, and delegate body encoders (JSON, XML, …)

feign.form.multipart.FormData<T>

A wrapper that lets callers supply an explicit filename and/or contentType for any single part, without changing the method signature:

interface UploadClient {
    @RequestLine("POST /upload")
    void upload(
        @Param("metadata") FormData<Metadata> metadata,
        @Param("file") File file
    );
}

// At call site:
client.upload(
    new FormData<>(metadata).contentType("application/json"),
    new File("report.pdf")
);

Note: import feign.form.multipart.FormData, not the legacy feign.form.FormData.

Built-in part type support

Part value type Behaviour
byte[] Sent as-is
java.io.File Streamed via Request.PathBody; filename and MIME type inferred from the file name
java.nio.file.Path Same as File; application/octet-stream fallback when MIME type cannot be guessed
java.io.InputStream Streamed via Request.InputStreamBody
FormData<?> Unwraps and re-resolves the inner content with optional overrides
Array (non-byte[]) Expands into one part per element
Iterable<?> Expands into one part per element
Any other object toString() fallback (same as legacy FormEncoder)
Object + explicit contentType Delegated to a registered Encoder (e.g. JacksonEncoder, Gson3Encoder)

feign.form.spring.MultipartFileEncoder (new, form-spring module)

Streams Spring's MultipartFile directly to the wire without loading the file into a byte[]:

@Bean
public Encoder feignFormEncoder() {
    return MultipartFormEncoder.builder()
        .partEncoders(encoders -> encoders.addFirst(new MultipartFileEncoder()))
        .build();
}

Architecture

The encoder is built around a chain-of-responsibility pattern with two orthogonal SPI layers:

MultipartFormEncoder
  └── PartResolverChain
        ├── FormDataPartResolver   // unwraps FormData<?>
        ├── ArrayPartResolver      // explodes arrays
        ├── IterablePartResolver   // explodes iterables
        └── LeafPartResolver       // hands off to PartEncoders
              ├── ByteArrayPartEncoder
              ├── FilePartEncoder  ──► PathPartEncoder
              ├── PathPartEncoder
              ├── InputStreamPartEncoder
              ├── DelegatingPartEncoder  // routes by Content-Type to Encoder
              └── DefaultPartEncoder     // toString() fallback

Both the resolver list and the encoder list are mutable before construction (Consumer<List<PartResolver>> / Consumer<List<PartEncoder>>), so callers can prepend, append, or replace entries without subclassing.

MultipartFormBody

Implements Request.Body and writes the RFC 7578 wire format directly to the OutputStream:

  • UUID-based random boundary, generated once per request.
  • contentLength() is accurate when all parts report a known size (so Content-Length is set precisely); propagates -1 when any part is unknown-length (e.g. InputStream).
  • isRepeatable() delegates to all parts.

API additions

feign.codec.Encoder

@FunctionalInterface            // <-- newly annotated
public interface Encoder {
    void encode(Object object, Type bodyType, RequestTemplate template)
        throws EncodeException;

    // NEW — lets DelegatingPartEncoder route by Content-Type
    default boolean supports(String contentType) { return true; }
}

feign.codec.JsonEncoder / feign.codec.XmlEncoder

Two new marker sub-interfaces that implement supports() with appropriate content-type regexes (e.g. \w+/(\w+\+)?json.*). Existing encoder implementations have been updated to implement the right interface:

Encoder Now implements
feign.json.JsonEncoder feign.codec.JsonEncoder
JacksonJrEncoder feign.codec.JsonEncoder
JacksonJaxbJsonEncoder feign.codec.JsonEncoder
JAXBEncoder (javax & jakarta) feign.codec.XmlEncoder
SOAPEncoder (javax & jakarta) feign.codec.XmlEncoder

This is the mechanism by which DelegatingPartEncoder knows which encoder to invoke for a part annotated with contentType("application/json").

Minor Request.Body fixes

  • PathBody and InputStreamBody — added equals() / hashCode().
  • PathBody.toString() — fixed whitespace: (N bytes) (N bytes).
  • BytesBody.writeTo() — removed redundant Objects.requireNonNull on outputStream (the contract already guarantees non-null).

Util.CONTENT_TYPE

Added a public "Content-Type" string constant to eliminate scattered inline literals.


Tests

Test class What it covers
StreamingMultipartFormTest WireMock-based: String, String[], byte[], File (text, octet-stream), Path, InputStream, List<String>, FormData<Movie> (JSON via JacksonEncoder), filename + content-type override via FormData
SpringStreamingMultipartFormTest Spring Boot + WireMock: MultipartFile streamed via MultipartFileEncoder

Both use WireMock 3.13.2, added as a test-scoped dependency in form/pom.xml and form-spring/pom.xml.

@yvasyliev yvasyliev marked this pull request as ready for review June 15, 2026 07:14
@velo velo merged commit 7163913 into OpenFeign:14.x Jun 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants