Skip to content
Merged
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)
* Added `feign.form.MultipartFormEncoder`, a new encoder for streaming multipart request bodies. It can stream
multipart parts from `File`, `Path`, `InputStream`, and custom `Request.Body` implementations without buffering the
whole payload in memory. (https://github.com/OpenFeign/feign/pull/3414)

### Version 13.12

Expand Down
7 changes: 7 additions & 0 deletions MIGRATION-v14.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ 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.

### `MultipartFormEncoder` streaming support (non-breaking) (https://github.com/OpenFeign/feign/pull/3414)

`feign.form.MultipartFormEncoder` was introduced to encode multipart requests as streaming `Request.Body` instances.
This is also additive and non-breaking: existing multipart clients continue to work, and users can opt into streaming
parts such as `File`, `Path`, `InputStream`, or custom `Request.Body` implementations by migrating from `FormEncoder` to
`MultipartFormEncoder`.

---

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

#### Streaming multipart/form-data (Feign 14+)

Starting with Feign 14, you can stream `multipart/form-data` request bodies with low memory overhead using
`feign.form.MultipartFormEncoder`. This encoder avoids loading entire file parts into memory, allowing you to transfer
large payloads directly via streaming types like `java.nio.file.Path`.

To customize part specifics (such as explicit filenames or content types), use the `feign.form.multipart.FormData`
wrapper object.

> [!NOTE]
> Ensure you import `feign.form.multipart.FormData` rather than the legacy `feign.form.FormData` container.

```java
class Pizza {
String name;
List<String> ingredients;
}

interface PizzaClient {
@RequestLine("POST /api/v1/pizzas")
void create(
@Param("category") String category,
@Param("image") File image,
@Param("pizza") FormData<Pizza> pizza
);
}

PizzaClient pizzaClient = Feign.builder()
.encoder(MultipartFormEncoder.builder()
.partBodyEncoders(List.of(new Jackson3Encoder()))
.build()
)
.target(PizzaClient.class, "https://api.pizza.com");

Pizza pizza = new Pizza();
pizza.name = "Margherita";
pizza.ingredients = List.of("Tomato", "Mozzarella", "Basil");

pizzaClient.create(
"premium-crust",

// to be streamed
new File("margherita.png"),

// to be converted to JSON via Jackson3Encoder
new FormData<>(pizza).contentType("application/json")
);
```

### 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 +1597,29 @@ public interface DownloadClient {
}
}
```

#### Streaming Spring MultipartFile (Feign 14+)

If you are using Spring Cloud OpenFeign and need to stream large `MultipartFile` payloads without consuming significant
JVM memory, you can use `feign.form.spring.MultipartFileEncoder`. This encoder processes files as streams instead of
loading their entire content into memory.

```java
@Configuration
public class StreamingMultipartConfig {

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

@FeignClient(name = "large-file-upload-service", configuration = StreamingMultipartConfig.class)
public interface LargeFileUploadClient {

@RequestMapping(value = "/upload", method = RequestMethod.POST, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
void uploadLargeFile(@RequestBody MultipartFile file);
}
```
50 changes: 48 additions & 2 deletions api/src/main/java/feign/Request.java
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,29 @@ public boolean isRepeatable() {
return true;
}

/**
* Indicates whether this {@link PathBody} is equal to another object.
*
* @param o {@inheritDoc}
* @return {@inheritDoc}
*/
@Override
public boolean equals(Object o) {
if (!(o instanceof PathBody)) return false;
PathBody pathBody = (PathBody) o;
return Objects.equals(path, pathBody.path);
}

/**
* Returns a hash code value for this {@link PathBody}.
*
* @return {@inheritDoc}
*/
@Override
public int hashCode() {
return Objects.hashCode(path);
}

/**
* Returns a string representation of the body content, which includes the file path and its
* size in bytes (or "unknown size" if the content length cannot be determined). This provides a
Expand All @@ -420,7 +443,7 @@ public String toString() {
long contentLength = contentLength();
String size = contentLength < 0 ? "unknown size" : contentLength + " bytes";

return "[Content of " + path + "(" + size + ")]";
return "[Content of " + path + " (" + size + ")]";
}
}

Expand Down Expand Up @@ -455,6 +478,29 @@ public void writeTo(OutputStream outputStream) throws IOException {
Util.copy(inputStream, outputStream);
}

/**
* Indicates whether this {@link InputStreamBody} is equal to another object.
*
* @param o {@inheritDoc}
* @return {@inheritDoc}
*/
@Override
public boolean equals(Object o) {
if (!(o instanceof InputStreamBody)) return false;
InputStreamBody that = (InputStreamBody) o;
return Objects.equals(inputStream, that.inputStream);
}

/**
* Returns a hash code value for this {@link InputStreamBody}.
*
* @return {@inheritDoc}
*/
@Override
public int hashCode() {
return Objects.hashCode(inputStream);
}

/**
* Returns a string representation of the body content, which is a binary data of unknown size.
*
Expand Down Expand Up @@ -666,7 +712,7 @@ private BodyImpl(byte[] content, Charset charset) {

@Override
public void writeTo(OutputStream outputStream) throws IOException {
Objects.requireNonNull(outputStream, "outputStream is required").write(content);
outputStream.write(content);
}

@Override
Expand Down
3 changes: 3 additions & 0 deletions api/src/main/java/feign/Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public class Util {
/** The HTTP Content-Encoding header field name. */
public static final String CONTENT_ENCODING = "Content-Encoding";

/** The HTTP Content-Type header field name. */
public static final String CONTENT_TYPE = "Content-Type";

/** The HTTP Accept-Encoding header field name. */
public static final String ACCEPT_ENCODING = "Accept-Encoding";

Expand Down
15 changes: 14 additions & 1 deletion api/src/main/java/feign/codec/Encoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* void create(User user);
* </pre>
*
* Example implementation: <br>
* <p>Example implementation: <br>
*
* <p>
*
Expand Down Expand Up @@ -66,6 +66,7 @@
* Session login(@Param(&quot;username&quot;) String username, @Param(&quot;password&quot;) String password);
* </pre>
*/
@FunctionalInterface
public interface Encoder {
/** Type literal for {@code Map<String, ?>}, indicating the object to encode is a form. */
Type MAP_STRING_WILDCARD = Util.MAP_STRING_WILDCARD;
Expand All @@ -80,4 +81,16 @@ public interface Encoder {
* @throws EncodeException when encoding failed due to a checked exception.
*/
void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException;

/**
* Indicates whether this encoder supports encoding the given content type. Default implementation
* returns {@code true} for all content types.
*
* @param contentType the content type to check for support
* @return {@code true} if this encoder supports encoding the given content type, {@code false}
* otherwise
*/
default boolean supports(String contentType) {
return true;
}
}
13 changes: 12 additions & 1 deletion api/src/main/java/feign/codec/JsonEncoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,15 @@
import feign.Experimental;

@Experimental
public interface JsonEncoder extends Encoder {}
public interface JsonEncoder extends Encoder {
/**
* {@inheritDoc}
*
* @param contentType {@inheritDoc}
* @return {@code true} if the given {@code contentType} is a JSON media type, {@code false}
*/
@Override
default boolean supports(String contentType) {
return contentType != null && contentType.trim().matches("(?i)\\w+/(?:[\\w._-]+\\+)?json.*");
}
}
31 changes: 31 additions & 0 deletions api/src/main/java/feign/codec/XmlEncoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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.codec;

/** An {@link Encoder} that encodes request bodies as XML. */
@FunctionalInterface
public interface XmlEncoder extends Encoder {
/**
* {@inheritDoc}
*
* @param contentType {@inheritDoc}
* @return {@code true} if the given {@code contentType} is an XML media type, {@code false}
*/
@Override
default boolean supports(String contentType) {
return contentType != null && contentType.trim().matches("(?i)\\w+/(?:[\\w._-]+\\+)?xml.*");
}
}
7 changes: 7 additions & 0 deletions form-spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<version>${wiremock.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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.form.spring;

import feign.Request;
import feign.form.multipart.AbstractPartEncoder;
import feign.form.multipart.Part;
import feign.form.multipart.PartMetadata;
import java.io.IOException;
import java.io.OutputStream;
import lombok.NonNull;
import org.springframework.web.multipart.MultipartFile;

/** Encoder for {@link MultipartFile} instances. */
public class MultipartFileEncoder extends AbstractPartEncoder {
/**
* Encodes the content of the part using given {@link MultipartFile}.
*
* @param partMetadata {@inheritDoc}
* @return {@inheritDoc}
*/
@Override
public Request.Body encode(PartMetadata partMetadata) {
var multipartFile = (MultipartFile) partMetadata.content().orElseThrow();

return new Part(
createHeaders(
multipartFile.getName(),
multipartFile.getOriginalFilename(),
multipartFile.getContentType()),
new MultipartFileBody(multipartFile));
}

/**
* {@inheritDoc}
*
* @param partMetadata {@inheritDoc}
* @return {@code true} if the content of the part is an instance of {@link MultipartFile}, {@code
* false} otherwise
*/
@Override
public boolean supports(PartMetadata partMetadata) {
return partMetadata.content().filter(MultipartFile.class::isInstance).isPresent();
}

private record MultipartFileBody(@NonNull MultipartFile multipartFile) implements Request.Body {
@Override
public void writeTo(OutputStream outputStream) throws IOException {
multipartFile.getInputStream().transferTo(outputStream);
}

@Override
public long contentLength() {
return multipartFile.getSize();
}

@Override
public String toString() {
return "[Binary data (" + contentLength() + " bytes)]";
}
}
}
Loading