Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* `DefaultEncoder` now supports streaming request bodies for `File`, `Path`, `InputStream`, and `Request.Body` types,
avoiding in-memory buffering. New `Request.PathBody` and `Request.InputStreamBody` implementations are provided for
these cases. (https://github.com/OpenFeign/feign/pull/3396)
* New `@Part` annotation and `MultipartFormEncoder` for declarative, streaming `multipart/form-data` requests.
`MultipartFileEncoder` (in `feign-form-spring`) extends this with direct Spring `MultipartFile` support.
(https://github.com/OpenFeign/feign/pull/3450)

### Version 13.12

Expand Down
9 changes: 9 additions & 0 deletions MIGRATION-v14.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ The **breaking changes** primarily affect code that interacts directly with requ
This is an additive, non-breaking change. Existing `DefaultEncoder` users do not need to modify code; users can now opt
into streaming by passing these types.

### `@Part` annotation & multipart encoder (non-breaking) (https://github.com/OpenFeign/feign/pull/3450)

A new `@Part` parameter annotation provides first-class, declarative support for `multipart/form-data` requests.
The `MultipartFormEncoder` (in `feign-form`) serializes `@Part`-annotated parameters into standards-compliant multipart
bodies with streaming support. For Spring users, `MultipartFileEncoder` (in `feign-form-spring`) extends this to
directly accept Spring's `MultipartFile` objects.

These are additive, non-breaking additions. Existing form-encoded and single-body contracts are unaffected.

---

## Breaking Changes
Expand Down
168 changes: 168 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,149 @@ In the example above, the `sendPhoto` method uses the `photo` parameter using th
someApi.sendPhoto(true, formData);
```

#### Streaming multipart with `@Part` and `MultipartFormEncoder` (since v14)

As of Feign v14, parts are streamed directly to the output without buffering the entire body in memory.
Declare multipart contracts with the `@Part` annotation and wire up `MultipartFormEncoder`:

```java
interface UploadApi {
@RequestLine("POST /upload")
void upload(
@Part("file") Path file,

// Collection/array parts are exploded by default (explode = true);
// each element becomes a separate part.
//
// Use @Part(value = "tags", explode = false) to send tags as a single part
// (assuming there's an Encoder that supports List<String> types).
@Part("tags") List<String> tags,

// Header templates accept @Param-expanded variables
@Part({
"Content-Disposition: form-data; name=\"custom\"",
"Content-Type: {contentType}"
})
String customPart,

@Param("contentType") String contentType
);
}

public class Example {
public static void main(String[] args) {
UploadApi client = Feign.builder()
.encoder(new MultipartFormEncoder())
.target(UploadApi.class, "https://example.com");

client.upload(
Path.of("photo.png"),
List.of("tag1", "tag2"),
"value",
"text/plain"
);
}
}
```

When `@Part` parameters are present, `MultipartFormEncoder` automatically sets the
`Content-Type: multipart/form-data` header with a generated boundary — no `@Headers` annotation needed.

##### Sending files

Sending files is straightforward:

```java
interface FileUploadApi {
@RequestLine("POST /upload")
void upload(
// Content-Disposition and Content-Type headers are generated automatically from the File's name and extension
@Part("file") File file,

// To override the filename and/or Content-Type auto-detection
@Part(headers = {
"Content-Disposition: form-data; name=\"custom\"; filename=\"custom.md\"",
"Content-Type: text/markdown"
})
Path customFile,

// To send InputStream (or String, byte[] etc.) as a file
@Part(headers = {
"Content-Disposition: form-data; name=\"stream\"; filename=\"{filename}\"",
"Content-Type: {contentType}"
})
InputStream stream,
@Param("filename") String filename,
@Param("contentType") String contentType
);
}

public class Example {
public static void main(String[] args) {
FileUploadApi client = Feign.builder()
.encoder(new MultipartFormEncoder())
.target(FileUploadApi.class, "https://example.com");

try (InputStream stream = new ByteArrayInputStream("Hello, world!".getBytes(StandardCharsets.UTF_8))) {
client.upload(
new File("photo.png"),
Path.of("my-file.txt"),
stream,
"hello.txt",
"text/plain"
);
}
}
}
```

##### Encoding arbitrary part body types with `ConditionalEncoder`

The `PartBodyFactory` accepts a chain of `Encoder` instances. Wrap each with `ConditionalEncoder`
and an `EncoderPredicate` to dispatch encoding based on the part's `Content-Type` header.
This works with any body type — JSON, XML, Protobuf, or custom formats:

```java
interface UploadApi {
@RequestLine("POST /upload")
void upload(
@Part({
"Content-Disposition: form-data; name=\"jsonPart\"",
"Content-Type: application/json"
})
JsonPayload jsonPart,

@Part({
"Content-Disposition: form-data; name=\"xmlPart\"",
"Content-Type: application/xml"
})
XmlPayload xmlPart
);
}

public class Example {
public static void main(String[] args) {
UploadApi client = Feign.builder()
.encoder(MultipartFormEncoder.builder()
.partBodyFactory(new PartBodyFactory(List.of(
new ConditionalEncoder(new GsonEncoder(),
EncoderPredicate.forContentType("application/json")),
new ConditionalEncoder(new JAXBEncoder(),
EncoderPredicate.forContentType("application/xml")),
new DefaultEncoder() // fallback for byte[], Path, InputStream etc.
)))
.build())
.target(UploadApi.class, "https://example.com");

client.upload(new JsonPayload(...), new XmlPayload(...));
}
}
```

Each `ConditionalEncoder` only fires when its predicate matches the part's declared `Content-Type`,
falling through to the next encoder in the chain otherwise. The last encoder acts as a catch-all
for untyped parts (`Path`, `byte[]`, `File`, etc.).

### Spring MultipartFile and Spring Cloud Netflix @FeignClient support

You can also use Form Encoder with Spring `MultipartFile` and `@FeignClient`.
Expand Down Expand Up @@ -1548,3 +1691,28 @@ public interface DownloadClient {
}
}
```

#### Streaming `MultipartFile` with `MultipartFileEncoder` (since v14)

As an alternative to `SpringFormEncoder`, `MultipartFileEncoder` extends `MultipartFormEncoder`
to stream Spring `MultipartFile` objects directly — no buffering in memory:

```java
@FeignClient(
name = "file-upload-service",
configuration = FileUploadServiceClient.MultipartSupportConfig.class
)
public interface FileUploadServiceClient extends IFileUploadServiceClient {

public class MultipartSupportConfig {

@Bean
public Encoder feignFormEncoder () {
return new MultipartFileEncoder(new MultipartFormEncoder());
}
}
}
```

`MultipartFileEncoder` handles single `MultipartFile`, `MultipartFile[]`, and `Collection<MultipartFile>`
parameters, automatically generating `Content-Disposition` and `Content-Type` headers.
12 changes: 12 additions & 0 deletions api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.squareup.okhttp3</groupId>
Expand Down
7 changes: 7 additions & 0 deletions api/src/main/java/feign/Contract.java
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ protected MethodMetadata parseAndValidateMetadata(Class<?> targetType, Method me
}
}

if (!data.partMetadata().isEmpty()) {
checkState(
data.bodyIndex() == null,
"Body parameters cannot be used with @Part parameters.%s",
data.warnings());
}

return data;
}

Expand Down
11 changes: 11 additions & 0 deletions api/src/main/java/feign/MethodMetadata.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public final class MethodMetadata implements Serializable {
private final Map<Integer, Class<? extends Expander>> indexToExpanderClass =
new LinkedHashMap<Integer, Class<? extends Expander>>();
private final Map<Integer, Boolean> indexToEncoded = new LinkedHashMap<Integer, Boolean>();
private final Map<Integer, PartMetadata> partMetadata = new LinkedHashMap<>();
private transient Map<Integer, Expander> indexToExpander;
private BitSet parameterToIgnore = new BitSet();
private boolean ignored;
Expand Down Expand Up @@ -159,6 +160,15 @@ public Map<Integer, Boolean> indexToEncoded() {
return indexToEncoded;
}

/**
* Returns metadata for {@link Part}-annotated parameters, keyed by parameter index.
*
* @return metadata for {@link Part}-annotated parameters, keyed by parameter index
*/
public Map<Integer, PartMetadata> partMetadata() {
return partMetadata;
}

/** If {@link #indexToExpander} is null, classes here will be instantiated by newInstance. */
public Map<Integer, Class<? extends Expander>> indexToExpanderClass() {
return indexToExpanderClass;
Expand Down Expand Up @@ -217,6 +227,7 @@ public boolean isAlreadyProcessed(Integer index) {
|| indexToName.containsKey(index)
|| indexToExpanderClass.containsKey(index)
|| indexToEncoded.containsKey(index)
|| partMetadata.containsKey(index)
|| (indexToExpander != null && indexToExpander.containsKey(index))
|| parameterToIgnore.get(index);
}
Expand Down
93 changes: 93 additions & 0 deletions api/src/main/java/feign/MultipartFormData.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package feign;

import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* A container for a list of resolved multipart parts and the invocation-time parameter map, passed
* to the encoder for header resolution and wire-format serialization.
*/
public class MultipartFormData {
private final List<PartData> parts;
private final Map<String, Object> variables;

/**
* Creates a multipart form body from a list of parts and a parameter map.
*
* @param parts list of multipart parts
* @param variables invocation-time parameter name to value map, used by the encoder to resolve
* {@code {name}} placeholders in part headers
*/
public MultipartFormData(List<PartData> parts, Map<String, Object> variables) {
this.parts = Objects.requireNonNull(parts, "parts must not be null");
this.variables = Objects.requireNonNull(variables, "variables must not be null");
}

/**
* Returns the list of multipart parts.
*
* @return the list of multipart parts
*/
public List<PartData> parts() {
return parts;
}

/**
* Returns the invocation-time parameter name to value map, used by the encoder to resolve {@code
* {name}} placeholders in part headers.
*
* @return the invocation-time parameter name to value map
*/
public Map<String, Object> variables() {
return variables;
}

/**
* {@inheritDoc}
*
* @param object {@inheritDoc}
* @return {@inheritDoc}
*/
@Override
public boolean equals(Object object) {
if (!(object instanceof MultipartFormData)) return false;
MultipartFormData that = (MultipartFormData) object;
return Objects.equals(parts, that.parts) && Objects.equals(variables, that.variables);
}

/**
* {@inheritDoc}
*
* @return {@inheritDoc}
*/
@Override
public int hashCode() {
return Objects.hash(parts, variables);
}

/**
* {@inheritDoc}
*
* @return {@inheritDoc}
*/
@Override
public String toString() {
return "MultipartFormData{" + "parts=" + parts + ", variables=" + variables + '}';
}
}
Loading