Skip to content

feat: add @Part annotation for multipart form-data request contracts#3450

Draft
yvasyliev wants to merge 3 commits into
OpenFeign:14.xfrom
yvasyliev:feature/multipart-contract
Draft

feat: add @Part annotation for multipart form-data request contracts#3450
yvasyliev wants to merge 3 commits into
OpenFeign:14.xfrom
yvasyliev:feature/multipart-contract

Conversation

@yvasyliev

@yvasyliev yvasyliev commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Co-authored-by: trumpetinc 6618744+trumpetinc@users.noreply.github.com

Summary

Another PR in the series related to #2734. Delivers the first part for the new multipart request API.

This PR introduces first-class, declarative support for multipart/form-data requests in Feign contracts through a new @Part parameter annotation.

This change is fully additive and non-breaking. Existing users and contracts will experience zero impact, as it introduces entirely new resolution pathways without altering standard form-encoded or single-body behaviors.

Note

This PR establishes the contract declaration API, compile-time metadata validation, and the runtime template generation factory. The underlying wire-serialization logic inside a dedicated Encoder is yet to be implemented and will follow in a subsequent pull request.


Usage Example

public interface FileUploadClient {
    @RequestLine("POST /api/v1/upload")
    // Content-Type: multipart/form-data can be omitted
    void uploadForm(
        // Shorthand notation; sufficient for 90% scenarios;
        // filename & Content-Type to be discovered & appended by a dedicated Encoder
        @Part("file")
        Path file,

        // Explicit single header
        @Part("Content-Disposition: form-data; name=\"metadata\"")
        byte[] metadata,

        // Multi-header declaration with param expansion
        @Part({
          "Content-Disposition: form-data; name=\"kv\"",
          "Content-Type: {customPartContentType}"
        })
        String customPart,

        @Param("customPartContentType")
        String customPartContentType,

        // Exploded collection part
        @Part(value = "tags", explode = true)
        List<String> tags
    );
}

API Design & Naming Rationale

During background design discussions, the API contract and naming conventions were carefully weighed to ensure maximum flexibility and architectural consistency with Feign's existing codebase:

1. The @Part Annotation

  • Why @Part instead of @Multipart or @FormData? The term multipart is fundamentally coupled to the nature of the entire HTTP request rather than an individual parameter. Conversely, form data is specific to a single header type (Content-Disposition). We needed a name that describes the entire standalone part entity (comprising its own distinct headers, metadata, and body payload). Hence, @Part is the most precise candidate.
  • Property Flexibility (value & headers):
    The value attribute acts as a concise alias for headers. This elegant symmetry allows developers to seamlessly scale from a simple shorthand string (@Part("partName")) to a full raw header line or a multi-line array of distinct headers.
  • The explode Flag:
    This property mirrors Feign's existing feign.CollectionFormat#EXPLODED configuration paradigm. It defines whether a collection or array should be unrolled into repeated separate parts on the wire.
  • Rejection of explicit name / filename attributes:
    An alternative design considered introducing dedicated name and filename properties directly onto the annotation. This was rejected because it introduces unneeded implementation complexity while arbitrarily limiting the user from injecting custom headers into individual parts.

2. MultipartFormData & PartData

  • MultipartFormData: Named to maintain a clear conceptual bridge to the legacy Map<String, Object> formData instances heavily utilized throughout Feign's core engine.
  • PartData: Extends the semantic pattern established by MultipartFormData, providing an isolated, intuitive representation of a specific runtime part payload.

Key Changes

1. Declarative API & Metadata

  • @Part Annotation: Defines the annotation interface with value, headers, and explode behaviors.
  • PartMetadata & PartData: Distinguishes compile-time contract definition metadata (PartMetadata) from runtime invocation state (PartData).
  • MultipartFormData: Holds the aggregated collection of runtime parts alongside standard invocation variables, ready to be passed downstream to the encoding layer.

2. Contract Integration & Validation

  • DefaultContract Parsing: Inspects parameters for @Part. If a single shorthand value lacking a colon (:) is provided, it automatically expands it into a standard Content-Disposition: form-data; name="..." structure.
  • Validation Defenses: Explicitly blocks invalid contract combinations (e.g., throwing an IllegalStateException if a contract attempts to mix an unannotated body parameter with @Part parameters, or if a user provides both value and headers attributes simultaneously).

3. Execution Factory Pathway

  • RequestTemplateFactoryResolver: Routes routing logic through a new BuildMultipartTemplateFromArgs factory whenever @Part structures are registered on a method contract.

Safety & Validation Rules Matrix

Scenario Contract Engine Behavior
@Part("fieldName") Translates to Content-Disposition: form-data; name="fieldName"
Mixed @Part and unannotated body param Throws IllegalStateException (Body conflict)
Missing both value and headers on @Part Throws IllegalStateException
Specifying both value and headers on @Part Throws IllegalStateException

Test Coverage

  • DefaultContractMultipartTest: Thoroughly asserts metadata generation correctness, shorthand header translation, error-state rejection, explicit generic type resolution, and multiple part handling.
  • BuildMultipartTemplateFromArgsTest: Confirms runtime template resolution factory behavior, ensuring variables map reliably and standard runtime execution faults transfer elegantly into Feign EncodeException types.

Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com>
@yvasyliev yvasyliev marked this pull request as draft June 24, 2026 20:14
Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com>
@trumpetinc

Copy link
Copy Markdown
Contributor

@yvasyliev nice!

I would like to encourage that we have a clear model of the filename handling - i.e. we get the Encoder part of the puzzle also completely figured out as part of this PR.

Specifically, I'm still not crystal clear on how the Encoder will handle putting the filename in the content disposition header without having multipart specific File and Path encoders.

I had originally suggested using a bean expression syntax to address this - is that still on the table, or are you thinking of something different?

@trumpetinc

trumpetinc commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@yvasyliev one big suggestion to consider:

I think the current PR may be pushing too much responsibility to the Encoder...

I think the place to handle sub-part encoding is in BuildMultipartTemplateFromArgs itself. Same with the unwrapping/unrolling/exploding collections.

Here's what that would look like (I haven't compiled this, so there may be some type-os):

  static class BuildMultipartTemplateFromArgs extends BuildTemplateByResolvingArgs {
    private final Encoder encoder;

    BuildMultipartTemplateFromArgs(
        MethodMetadata metadata,
        Encoder encoder,
        QueryMapEncoder queryMapEncoder,
        Target<?> target) {
      super(metadata, queryMapEncoder, target);
      this.encoder = encoder;
    }

// everything above this is the same as in your code

    @Override
    protected RequestTemplate resolve(
        Object[] argv, RequestTemplate mutable, Map<String, Object> variables) {
      List<PartData> parts =
          metadata.partMetadata().entrySet().stream()
              // not shown, but I think this is where you should call the expander...
              .map(
                  entry -> {
                    PartMetadata partMeta = entry.getValue();

                    // construct a separate RequestTemplate for the part, resolve variables, and encode the body
                    RequestTemplate partRequestTemplate = new RequestTemplate(); 
                    partRequestTemplate.headers(partMeta.headers());
                    partRequestTemplate = partRequestTemplate.resolve(variables);
                    encoder.encode( argv[entry.getKey()], partMeta.type(), partRequestTemplate); // note that we have access to the configured Feign encoder here

                   // now create a PartData from the info in the part's RequestTemplate
                    return new PartData(partRequestTemplate.getHeaders(), partRequestTemplate.getBody())
                  })
              .collect(Collectors.toList());

// everything below this is the same as your code

      MultipartFormData formData = new MultipartFormData(parts);

      try {
        encoder.encode(formData, MultipartFormData.class, mutable);
      } catch (EncodeException e) {
        throw e;
      } catch (RuntimeException e) {
        throw new EncodeException(e.getMessage(), e);
      }

      return super.resolve(argv, mutable, variables);
    }
  }

Basically, BuildMultipartTemplateFromArgs would produce the exact MultiPartFormData object that a user would create manually if they weren't using annotations. This keeps the architecture nice and encapsulated.

The multipart encoder then becomes very simple - it just adds the separator header to the request template, then it creates a Body that adds the separator and part headers, plus calls each part Body to emit its data to the output beneath each part header.

If we have good separation of concerns, the user should be able to register a MultiPartFormDataEncoder in Feign config then do either of these and get the same behavior:

// option 1
public interface FileUploadClient {
    @RequestLine("POST /api/v1/upload")
    void uploadForm(
        // Shorthand notation; sufficient for 90% scenarios;
        // filename & Content-Type to be discovered & appended by a dedicated Encoder
        @Part("file")
        Path file
   );

//

uploadClient.uploadForm(myPath);

or

// option 2
public interface FileUploadClient {
    @RequestLine("POST /api/v1/upload")
    void uploadForm(
        MultiPartFormData multiPartFormData
   );

//

uploadClient.uploadForm(
  MultiPartFormData.builder()
    .part(MultiPartForm
    .body(
        PartData.builder()
        .header("Content-Type: application/xml") // or whatever
        .header("Content-Disposition: form-part; name="file"; filename="aaaaa.xml")
        .body(new Request.PathBody(myPath)
        .build()
    )
    .build()
);

If the user can do either of those with the same result (and not having to jump through hoops in the back-end code to make it happen), then we will have really good encapsulation.

@yvasyliev

yvasyliev commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

So, BuildMultipartTemplateFromArgs delegates parts encoding to the Encoder, packs them into a MultipartFormData, and delegates the MultipartFormData to the same encoder again. Doesn't this flow violate the single responsibility principle? I always thought that the body transformation/expansion/explosion is the Encoder's jurisdiction.

Like the legacy Map<String, Object> formData, MultipartFormData is an intermediate state between raw body metadata and the transfer-ready Request.Body. In this POC, I don't expect MultipartFormData to be available for regular users. Instead, I expect the Encoder to map the MultipartFormData (and its parts) to a MultipartFormBody. And the MultipartFormBody is something we can expose to the regular users.

This way, your second option becomes:

// option 2
public interface FileUploadClient {
    @RequestLine("POST /api/v1/upload")
    void uploadForm(MultipartFormBody body);
}

//

uploadClient.uploadForm(new MultipartFormBody(
    "my-own-boundary",
    List.of(new PartBody(
        Map.of(
            "Content-Disposition", List.of("form-data; name=\"file\"; filename=\"aaaaa.xml\""),
            "Content-Type", List.of("application/xml")
        ),
        new Request.PathBody(myPath)
    ))
));

Does it work for you?

@trumpetinc

trumpetinc commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

ok - I see that I may have misinterpreted the MultiPartFormData class. I don't see a MultiPartFormBody class in the PR - maybe this is why I got confused?

As for concerns, here's how I look at it:

The Contract is concerned with reading the interface method signatures and building a meta object.

The RequestTemplateFactoryResolver is about combining the actual arguments of the method call (which can contain a Java object that represents the body) and the method meta to construct a resolved RequestTemplate. The construction of a RequestTemplate requires an Encoder to convert the Java object argument to a Body in the RequestTemplate (recognizing that this encoding may require some reading and/or setting of the request headers).

My strong opinion is that Encoders are concerned only with converting a Java object into the body+headers required for the request to be sent. When we make this distinction, very good things happen to the architecture (including your original very important observation that encoders should be usable for multi-part section encoding). When the encoders try to be responsible for more than that one concern, all sorts of dependencies and limitations show up.

With multi-part, the parallel between the sub-part and Request is very, very strong, and I think we can take advantage of that to really simplify the coding and make things more intuitive for users.

I am pretty confident about my description of the Encoder's (limited) concern because of the following:

  1. If we try to put responsibility of creating the part bodies into the MultiPartFormEncoder, we immediately have the problem that MultiPartFormEncoder doesn't have access to the Feign configured Encoder. This is why the current multipart handler has effectively recreated the entire encoder functionality. I think this is also why your previous proof of concept had explicit encoder registration with the MultiPartFormEncoder itself. We could "solve" this by putting the Feign configured Encoder into the MultiPartFormData object so it will be available to the form encoder, but this is getting really messy. We were basically fighting against the natural responsibilities of the Feign architecture.
  2. Collection expansion already happens in the RequestTemplateFactoryResolver. So the original Feign implementation already acknowledges where that behavior belongs.
  3. Resolving header templates and bodies is already done in RequestTemplateFactoryResolver (it's actually the name of the class!). In the PoC PR, we are having to package all of the information from RequestTemplateFactoryResolver into MultiPartFormData just so MultiPartFormEncoder can do the work. It is much cleaner to just do that processing right there in RequestTemplateFactoryResolver.

One thing that is certain: Encoders are designed to work with RequestTemplate. So if we want to re-use Encoders (and I think that we really, really do), then it makes a lot of sense to use RequestTemplate and resolve it from within the RequestTemplateFactoryResolver.

Unfortunately, I don't think that the MultiPartFormData class as written is very helpful - you can see from my BuildMultipartTemplateFromArgs sample code I posted earlier that we have all the information we need to construct a fully defined PartData with all headers and the body. Capturing the variables, header templates, etc... in the MultiPartFormData then passing that to the MultiPartFormEncoder doesn't seem to add much. Much cleaner to just create a MultiPartFormBody (which is really just a collection of PartDatas, or maybe we should call them PartRequests ??).

Fundamentally, I see many reasons to do this request template resolution in RequestTemplateFactoryResolver, but I can't see much benefit to trying to do any of that in the encoder.

For thoroughness, here is what I think the MultiPartFormEncoder would look like - very simple, focused only on encoding a well defined MultiPartFormBody:

public   void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException{
  // not shown - make sure bodyType is MultiPartFormBody
  final MultiPartFormBody formBody = (MultiPartFormBody)object;
  final String boundary = // generate random - don't make the user provide it

  template.headers()....  // set the content-type header including boundary

  // our Body implementation becomes super straightforward
  template.body().writeTo(os -> {
    formBody.parts().forEach(part -> {
       emitBoundary(os, boundary);
       part.headers().entrySet().forEach(entry -> emitHeader(os, entry.key(), entry.value()));
       emitNewLine(os);
       part.body().writeTo(os);
    }

   // add closing boundary (I'm pretty sure that is required by the spec, but I haven't double checked)
   emitBoundary(boundary);

  };

}

private void emitBoundary(OutputStream os, String boundary) {...}
private void emitHeader(OutputStream os, String headerName, Collection<String> headerVals ) {...}
private void emitNewline(OutputStream os) {...}

Note that this encoder does not need access to any other encoders - because the part headers and body have already been resolved.

Does that make sense?

Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com>
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