diff --git a/README.md b/README.md
index ef59509c8..78936bcb4 100644
--- a/README.md
+++ b/README.md
@@ -1471,11 +1471,22 @@ interface PizzaClient {
);
}
+Encoder jackson3Encoder = new ConditionalEncoder(
+ new Jackson3Encoder(),
+ EncoderPredicate.forContentType(MediaType.APPLICATION_JSON_VALUE)
+);
+PartBodyFactory partBodyFactory = new PartBodyFactory(encoders -> encoders.add(0, jackson3Encoder));
+PartFactory partFactory = PartFactory.builder()
+ .partBodyFactory(partBodyFactory)
+ .build();
+MultipartFormBodyFactory multipartFormBodyFactory = MultipartFormBodyFactory.builder()
+ .partFactory(partFactory)
+ .build();
+MultipartFormEncoder multipartFormEncoder = MultipartFormEncoder.builder()
+ .multipartFormBodyFactory(multipartFormBodyFactory)
+ .build();
PizzaClient pizzaClient = Feign.builder()
- .encoder(MultipartFormEncoder.builder()
- .partBodyEncoders(List.of(new Jackson3Encoder()))
- .build()
- )
+ .encoder(multipartFormEncoder)
.target(PizzaClient.class, "https://api.pizza.com");
Pizza pizza = new Pizza();
@@ -1601,8 +1612,10 @@ 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.
+JVM memory, configure `MultipartFormEncoder` with:
+
+- `MultipartFilePartContextResolver` to unwrap Spring `MultipartFile` metadata
+- `MultipartFileEncoder` as the part body encoder
```java
@Configuration
@@ -1610,8 +1623,15 @@ public class StreamingMultipartConfig {
@Bean
public Encoder feignFormEncoder() {
+ var partFactory = PartFactory.builder()
+ .partBodyFactory(new PartBodyFactory(List.of(new MultipartFileEncoder())))
+ .build();
+ var multipartFormBodyFactory = new MultipartFormBodyFactory(
+ new PartContextResolverChain(resolvers -> resolvers.addFirst(new MultipartFilePartContextResolver())),
+ partFactory
+ );
return MultipartFormEncoder.builder()
- .partEncoders(encoders -> encoders.addFirst(new MultipartFileEncoder()))
+ .multipartFormBodyFactory(multipartFormBodyFactory)
.build();
}
}
@@ -1619,7 +1639,11 @@ public class StreamingMultipartConfig {
@FeignClient(name = "large-file-upload-service", configuration = StreamingMultipartConfig.class)
public interface LargeFileUploadClient {
- @RequestMapping(value = "/upload", method = RequestMethod.POST, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @RequestMapping(
+ value = "/upload",
+ method = RequestMethod.POST,
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE
+ )
void uploadLargeFile(@RequestBody MultipartFile file);
}
```
diff --git a/api/src/main/java/feign/Request.java b/api/src/main/java/feign/Request.java
index 2de930624..1b96cb639 100644
--- a/api/src/main/java/feign/Request.java
+++ b/api/src/main/java/feign/Request.java
@@ -725,6 +725,18 @@ public boolean isRepeatable() {
return true;
}
+ @Override
+ public boolean equals(Object object) {
+ if (!(object instanceof BodyImpl)) return false;
+ BodyImpl body = (BodyImpl) object;
+ return Objects.deepEquals(content, body.content) && Objects.equals(charset, body.charset);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(Arrays.hashCode(content), charset);
+ }
+
@Override
public String toString() {
return new String(content, charset);
diff --git a/api/src/main/java/feign/codec/Encoder.java b/api/src/main/java/feign/codec/Encoder.java
index 8cfc09983..1979580a7 100644
--- a/api/src/main/java/feign/codec/Encoder.java
+++ b/api/src/main/java/feign/codec/Encoder.java
@@ -31,7 +31,7 @@
* void create(User user);
*
*
- *
Example implementation:
+ * Example implementation:
*
*
*
@@ -66,7 +66,6 @@
* Session login(@Param("username") String username, @Param("password") String password);
*
*/
-@FunctionalInterface
public interface Encoder {
/** Type literal for {@code Map}, indicating the object to encode is a form. */
Type MAP_STRING_WILDCARD = Util.MAP_STRING_WILDCARD;
@@ -81,16 +80,4 @@ 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;
- }
}
diff --git a/api/src/main/java/feign/codec/JsonEncoder.java b/api/src/main/java/feign/codec/JsonEncoder.java
index c249fd06b..47d800e74 100644
--- a/api/src/main/java/feign/codec/JsonEncoder.java
+++ b/api/src/main/java/feign/codec/JsonEncoder.java
@@ -18,15 +18,4 @@
import feign.Experimental;
@Experimental
-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.*");
- }
-}
+public interface JsonEncoder extends Encoder {}
diff --git a/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java b/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java
index 248364b1e..b4c98c1ce 100644
--- a/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java
+++ b/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java
@@ -16,44 +16,30 @@
package feign.form.spring;
import feign.Request;
-import feign.form.multipart.AbstractPartEncoder;
-import feign.form.multipart.Part;
-import feign.form.multipart.PartMetadata;
+import feign.RequestTemplate;
+import feign.form.multipart.ConditionalEncoder;
import java.io.IOException;
import java.io.OutputStream;
+import java.lang.reflect.Type;
import lombok.NonNull;
import org.springframework.web.multipart.MultipartFile;
/** Encoder for {@link MultipartFile} instances. */
-public class MultipartFileEncoder extends AbstractPartEncoder {
+public class MultipartFileEncoder extends ConditionalEncoder {
/**
- * Encodes the content of the part using given {@link MultipartFile}.
- *
- * @param partMetadata {@inheritDoc}
- * @return {@inheritDoc}
+ * Creates a new instance of {@link MultipartFileEncoder} that encodes {@link MultipartFile}
+ * instances.
*/
- @Override
- public Request.Body encode(PartMetadata partMetadata) {
- var multipartFile = (MultipartFile) partMetadata.content().orElseThrow();
+ public MultipartFileEncoder() {
+ super(MultipartFileEncoder::doEncode, MultipartFileEncoder::isMultipartFile);
+ }
- return new Part(
- createHeaders(
- multipartFile.getName(),
- multipartFile.getOriginalFilename(),
- multipartFile.getContentType()),
- new MultipartFileBody(multipartFile));
+ private static void doEncode(Object object, Type bodyType, RequestTemplate template) {
+ template.body(new MultipartFileBody((MultipartFile) object));
}
- /**
- * {@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 static boolean isMultipartFile(Object object, Type bodyType, RequestTemplate template) {
+ return object instanceof MultipartFile;
}
private record MultipartFileBody(@NonNull MultipartFile multipartFile) implements Request.Body {
diff --git a/form-spring/src/main/java/feign/form/spring/MultipartFilePartContextResolver.java b/form-spring/src/main/java/feign/form/spring/MultipartFilePartContextResolver.java
new file mode 100644
index 000000000..6b1a5fa5b
--- /dev/null
+++ b/form-spring/src/main/java/feign/form/spring/MultipartFilePartContextResolver.java
@@ -0,0 +1,57 @@
+/*
+ * 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.form.multipart.PartContext;
+import feign.form.multipart.PartContextResolver;
+import feign.form.multipart.PartContextResolverChain;
+import java.util.stream.Stream;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * A {@link PartContextResolver} implementation that resolves a {@link PartContext} containing a
+ * {@link MultipartFile} into a {@link PartContext} with the unwrapped content of the {@link
+ * MultipartFile}.
+ */
+public class MultipartFilePartContextResolver implements PartContextResolver {
+ /**
+ * {@inheritDoc}
+ *
+ * @param partContext {@inheritDoc}
+ * @param chain {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Stream resolve(PartContext partContext, PartContextResolverChain chain) {
+ var resolvedPartContext =
+ partContext
+ .content()
+ .filter(MultipartFile.class::isInstance)
+ .map(
+ content -> {
+ var multipartFile = (MultipartFile) content;
+
+ return partContext.toBuilder()
+ .name(multipartFile.getName())
+ .filename(multipartFile.getOriginalFilename())
+ .contentType(multipartFile.getContentType())
+ .build();
+ })
+ .orElse(partContext);
+
+ return chain.resolve(resolvedPartContext);
+ }
+}
diff --git a/form-spring/src/test/java/feign/form/feign/spring/SpringStreamingMultipartFormTest.java b/form-spring/src/test/java/feign/form/feign/spring/SpringStreamingMultipartFormTest.java
index 59960c5ba..1ad61ee63 100644
--- a/form-spring/src/test/java/feign/form/feign/spring/SpringStreamingMultipartFormTest.java
+++ b/form-spring/src/test/java/feign/form/feign/spring/SpringStreamingMultipartFormTest.java
@@ -29,8 +29,14 @@
import feign.Util;
import feign.codec.Encoder;
import feign.form.MultipartFormEncoder;
+import feign.form.multipart.MultipartFormBodyFactory;
+import feign.form.multipart.PartBodyFactory;
+import feign.form.multipart.PartContextResolverChain;
+import feign.form.multipart.PartFactory;
import feign.form.spring.MultipartFileEncoder;
+import feign.form.spring.MultipartFilePartContextResolver;
import java.nio.charset.StandardCharsets;
+import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -91,8 +97,17 @@ private interface SpringStreamingMultipartFormTestClient {
private static class SpringStreamingMultipartFormTestConfiguration {
@Bean
public Encoder feignEncoder() {
+ var partFactory =
+ PartFactory.builder()
+ .partBodyFactory(new PartBodyFactory(List.of(new MultipartFileEncoder())))
+ .build();
+ var multipartFormBodyFactory =
+ new MultipartFormBodyFactory(
+ new PartContextResolverChain(
+ resolvers -> resolvers.addFirst(new MultipartFilePartContextResolver())),
+ partFactory);
return MultipartFormEncoder.builder()
- .partEncoders(encoders -> encoders.addFirst(new MultipartFileEncoder()))
+ .multipartFormBodyFactory(multipartFormBodyFactory)
.build();
}
}
diff --git a/form/src/main/java/feign/form/MultipartFormEncoder.java b/form/src/main/java/feign/form/MultipartFormEncoder.java
index 83ce9970d..c1a64a3ad 100644
--- a/form/src/main/java/feign/form/MultipartFormEncoder.java
+++ b/form/src/main/java/feign/form/MultipartFormEncoder.java
@@ -20,122 +20,45 @@
import feign.codec.EncodeException;
import feign.codec.Encoder;
import feign.core.codec.DefaultEncoder;
-import feign.form.multipart.ArrayPartResolver;
-import feign.form.multipart.ByteArrayPartEncoder;
-import feign.form.multipart.DefaultPartEncoder;
-import feign.form.multipart.DelegatingPartEncoder;
-import feign.form.multipart.FilePartEncoder;
-import feign.form.multipart.FormDataPartResolver;
-import feign.form.multipart.InputStreamPartEncoder;
-import feign.form.multipart.IterablePartResolver;
-import feign.form.multipart.LeafPartResolver;
-import feign.form.multipart.MultipartFormBody;
-import feign.form.multipart.PartEncoder;
-import feign.form.multipart.PartMetadata;
-import feign.form.multipart.PartResolver;
-import feign.form.multipart.PartResolverChain;
-import feign.form.multipart.PathPartEncoder;
+import feign.form.multipart.MultipartFormBodyFactory;
import feign.form.util.PojoUtil;
import java.lang.reflect.Type;
-import java.util.Collection;
import java.util.Collections;
-import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
-import java.util.function.Consumer;
-import java.util.stream.Collectors;
+import lombok.AllArgsConstructor;
import lombok.Builder;
+import lombok.NoArgsConstructor;
import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
/** An {@link Encoder} that encodes a request body as {@code multipart/form-data}. */
-@RequiredArgsConstructor
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
public class MultipartFormEncoder implements Encoder {
- @NonNull private final Encoder delegate;
+ @NonNull @Builder.Default private final Encoder delegate = new DefaultEncoder();
- @NonNull private final PartResolverChain partResolverChain;
+ @NonNull @Builder.Default
+ private final MultipartFormBodyFactory multipartFormBodyFactory = new MultipartFormBodyFactory();
- /**
- * Creates a new {@link MultipartFormEncoder} with a {@link DefaultEncoder} delegate and a default
- * {@link PartResolverChain} that supports {@link FormData}, arrays, iterables, and scalar parts.
- */
- public MultipartFormEncoder() {
- this((Encoder) null);
+ private static boolean isMultipart(RequestTemplate template) {
+ return template.headers().getOrDefault(Util.CONTENT_TYPE, List.of()).stream()
+ .anyMatch(MultipartFormEncoder::isMultipart);
}
- /**
- * Creates a new {@link MultipartFormEncoder} with the given delegate {@link Encoder} and a
- * default {@link PartResolverChain} that supports {@link FormData}, arrays, iterables, and scalar
- * parts.
- *
- * @param delegate the delegate {@link Encoder} to use for encoding non-multipart request bodies.
- * If {@code null}, a default {@link DefaultEncoder} will be used.
- */
- public MultipartFormEncoder(Encoder delegate) {
- this(delegate, null, null, null);
+ private static boolean isMultipart(String contentType) {
+ return contentType.trim().toLowerCase().startsWith("multipart/form-data");
}
- /**
- * Creates a new {@link MultipartFormEncoder} with the given {@link PartResolverChain} and a
- * default {@link DefaultEncoder} delegate.
- *
- * @param partResolverChain the {@link PartResolverChain} to use for resolving multipart parts. If
- * {@code null}, a default {@link PartResolverChain} that supports {@link FormData}, arrays,
- * iterables, and scalar parts will be used.
- */
- public MultipartFormEncoder(PartResolverChain partResolverChain) {
- this(defaultDelegate(), partResolverChain);
- }
-
- @Builder
- private MultipartFormEncoder(
- Encoder delegate,
- Consumer> partResolvers,
- Consumer> partEncoders,
- Collection partBodyEncoders) {
- this(
- Objects.requireNonNullElseGet(delegate, MultipartFormEncoder::defaultDelegate),
- buildPartResolverOrchestrator(
- partResolvers,
- partEncoders,
- Objects.requireNonNullElseGet(partBodyEncoders, Collections::emptyList)));
- }
-
- private static Encoder defaultDelegate() {
- return new DefaultEncoder();
- }
-
- private static PartResolverChain buildPartResolverOrchestrator(
- Consumer> partResolversCustomizer,
- Consumer> partEncodersCustomizer,
- Collection partBodyEncoders) {
- var pathPartEncoder = new PathPartEncoder();
- var partEncoders = new LinkedList();
-
- partEncoders.add(new ByteArrayPartEncoder());
- partEncoders.add(new FilePartEncoder(pathPartEncoder));
- partEncoders.add(pathPartEncoder);
- partEncoders.add(new InputStreamPartEncoder());
- partEncoders.add(new DelegatingPartEncoder(partBodyEncoders));
- partEncoders.add(new DefaultPartEncoder());
-
- if (partEncodersCustomizer != null) {
- partEncodersCustomizer.accept(partEncoders);
- }
-
- var partResolvers = new LinkedList();
-
- partResolvers.add(new FormDataPartResolver());
- partResolvers.add(new ArrayPartResolver());
- partResolvers.add(new IterablePartResolver());
- partResolvers.add(new LeafPartResolver(partEncoders));
+ private static Map getFormData(Object object, Type bodyType) {
+ if (object instanceof Map) {
+ @SuppressWarnings("unchecked")
+ var formData = (Map) object;
- if (partResolversCustomizer != null) {
- partResolversCustomizer.accept(partResolvers);
+ return formData;
}
- return new PartResolverChain(partResolvers);
+ return PojoUtil.isUserPojo(bodyType) ? PojoUtil.toMap(object) : Collections.emptyMap();
}
/**
@@ -162,45 +85,11 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
return;
}
- var multipartFormBody =
- formData.entrySet().stream()
- .flatMap(
- entry ->
- partResolverChain.resolve(new PartMetadata(entry.getKey(), entry.getValue())))
- .collect(Collectors.collectingAndThen(Collectors.toList(), MultipartFormBody::new));
+ var multipartFormBody = multipartFormBodyFactory.create(formData);
- template.header(Util.CONTENT_TYPE, Collections.emptyList()); // reset header
- template.header(
+ template.removeHeader(Util.CONTENT_TYPE);
+ template.headerLiteral(
Util.CONTENT_TYPE, "multipart/form-data; boundary=" + multipartFormBody.boundary());
template.body(multipartFormBody);
}
-
- /**
- * {@inheritDoc}
- *
- * @param contentType {@inheritDoc}
- * @return {@code true} if the given {@code contentType} is not {@code null} and starts with
- * {@code multipart/form-data} (case-insensitive), {@code false} otherwise.
- */
- @Override
- public boolean supports(String contentType) {
- return contentType != null
- && contentType.trim().toLowerCase().startsWith("multipart/form-data");
- }
-
- private boolean isMultipart(RequestTemplate template) {
- return template.headers().getOrDefault(Util.CONTENT_TYPE, Collections.emptyList()).stream()
- .anyMatch(this::supports);
- }
-
- private Map getFormData(Object object, Type bodyType) {
- if (object instanceof Map) {
- @SuppressWarnings("unchecked")
- var formData = (Map) object;
-
- return formData;
- }
-
- return PojoUtil.isUserPojo(bodyType) ? PojoUtil.toMap(object) : Collections.emptyMap();
- }
}
diff --git a/form/src/main/java/feign/form/multipart/ArrayPartContextResolver.java b/form/src/main/java/feign/form/multipart/ArrayPartContextResolver.java
new file mode 100644
index 000000000..e859d9e5e
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/ArrayPartContextResolver.java
@@ -0,0 +1,53 @@
+/*
+ * 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.multipart;
+
+import java.lang.reflect.Array;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+/**
+ * Resolves a {@link PartContext} that contains an array into multiple {@link PartContext}
+ * instances, one for each element in the array.
+ */
+public class ArrayPartContextResolver implements PartContextResolver {
+ /**
+ * {@inheritDoc}
+ *
+ * @param partContext {@inheritDoc}
+ * @param chain {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Stream resolve(PartContext partContext, PartContextResolverChain chain) {
+ return partContext
+ .content()
+ .filter(content -> content.getClass().isArray() && !(content instanceof byte[]))
+ .map(
+ array ->
+ IntStream.range(0, Array.getLength(array))
+ .mapToObj(
+ i -> {
+ var flattenedPartMetadata =
+ partContext.toBuilder().content(Array.get(array, i)).build();
+
+ return chain.resolve(flattenedPartMetadata);
+ })
+ .flatMap(Function.identity()))
+ .orElseGet(() -> chain.resolve(partContext));
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/ArrayPartResolver.java b/form/src/main/java/feign/form/multipart/ArrayPartResolver.java
deleted file mode 100644
index 55268ca3b..000000000
--- a/form/src/main/java/feign/form/multipart/ArrayPartResolver.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import feign.codec.EncodeException;
-import java.lang.reflect.Array;
-import java.util.function.Function;
-import java.util.stream.IntStream;
-import java.util.stream.Stream;
-
-/**
- * Resolves array content by creating a part for each element in the array. Byte arrays are excluded
- * since they are commonly used to represent binary data and should be treated as a single part.
- */
-public class ArrayPartResolver implements PartResolver {
- /**
- * Resolves the given part metadata by delegating each array element to the {@code chain}.
- *
- * @param partMetadata {@inheritDoc}
- * @param chain {@inheritDoc}
- * @return {@inheritDoc}
- * @throws EncodeException {@inheritDoc}
- */
- @Override
- public Stream resolve(PartMetadata partMetadata, PartResolverChain chain)
- throws EncodeException {
- var content = partMetadata.content().orElseThrow();
-
- return IntStream.range(0, Array.getLength(content))
- .mapToObj(
- i ->
- chain.resolve(
- new PartMetadata(
- partMetadata.name(),
- Array.get(content, i),
- partMetadata.filenameOverride().orElse(null),
- partMetadata.contentTypeOverride().orElse(null))))
- .flatMap(Function.identity());
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if the content is an array (excluding byte arrays), {@code false}
- * otherwise
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata
- .content()
- .filter(content -> content.getClass().isArray() && !(content instanceof byte[]))
- .isPresent();
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/ByteArrayPartEncoder.java b/form/src/main/java/feign/form/multipart/ByteArrayPartEncoder.java
deleted file mode 100644
index f1d62cee5..000000000
--- a/form/src/main/java/feign/form/multipart/ByteArrayPartEncoder.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-
-/** A {@link PartEncoder} that encodes a part with a byte array content. */
-public class ByteArrayPartEncoder extends AbstractPartEncoder {
- /**
- * Creates a {@link Request.Body} from a given byte array part content.
- *
- * @param partMetadata {@inheritDoc}
- * @return {@inheritDoc}
- */
- @Override
- public Request.Body encode(PartMetadata partMetadata) {
- return new Part(
- createHeaders(
- partMetadata.name(),
- partMetadata.filenameOverride().orElse(null),
- partMetadata.contentTypeOverride().orElse(null)),
- Request.Body.of((byte[]) partMetadata.content().orElseThrow()));
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if the part content is a byte array, {@code false} otherwise
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata.content().filter(byte[].class::isInstance).isPresent();
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/CollectionPartContextResolver.java b/form/src/main/java/feign/form/multipart/CollectionPartContextResolver.java
new file mode 100644
index 000000000..85216a3ed
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/CollectionPartContextResolver.java
@@ -0,0 +1,52 @@
+/*
+ * 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.multipart;
+
+import java.util.Collection;
+import java.util.stream.Stream;
+
+/**
+ * A {@link PartContextResolver} implementation that resolves a {@link PartContext} containing a
+ * {@link Collection} into a stream of {@link PartContext} instances, one for each item in the
+ * collection.
+ */
+public class CollectionPartContextResolver implements PartContextResolver {
+ /**
+ * {@inheritDoc}
+ *
+ * @param partContext {@inheritDoc}
+ * @param chain {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Stream resolve(PartContext partContext, PartContextResolverChain chain) {
+ return partContext
+ .content()
+ .filter(Collection.class::isInstance)
+ .map(
+ collection ->
+ ((Collection>) collection)
+ .stream()
+ .flatMap(
+ item -> {
+ var flattenedPartMetadata =
+ partContext.toBuilder().content(item).build();
+
+ return chain.resolve(flattenedPartMetadata);
+ }))
+ .orElseGet(() -> chain.resolve(partContext));
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/ConditionalEncoder.java b/form/src/main/java/feign/form/multipart/ConditionalEncoder.java
new file mode 100644
index 000000000..0de4a54a1
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/ConditionalEncoder.java
@@ -0,0 +1,51 @@
+/*
+ * 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.multipart;
+
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+import java.lang.reflect.Type;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * A {@link Encoder} that checks if the given object can be encoded. If it can, it delegates the
+ * encoding to another {@link Encoder}. Otherwise, it throws an {@link EncodeException}.
+ */
+@RequiredArgsConstructor
+public class ConditionalEncoder implements Encoder {
+ @NonNull private final Encoder delegate;
+ @NonNull private final EncoderPredicate encoderPredicate;
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param object {@inheritDoc}
+ * @param bodyType {@inheritDoc}
+ * @param template {@inheritDoc}
+ * @throws EncodeException {@inheritDoc}
+ */
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template)
+ throws EncodeException {
+ if (!encoderPredicate.test(object, bodyType, template)) {
+ throw new EncodeException("Unsupported object received for encoding: " + object);
+ }
+
+ delegate.encode(object, bodyType, template);
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/DefaultPartEncoder.java b/form/src/main/java/feign/form/multipart/DefaultPartEncoder.java
deleted file mode 100644
index 1af5707b4..000000000
--- a/form/src/main/java/feign/form/multipart/DefaultPartEncoder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-
-/** Default implementation of {@link PartEncoder}. Supports all types of parts. */
-public class DefaultPartEncoder extends AbstractPartEncoder {
- /**
- * Creates a {@link Request.Body} from the given {@link PartMetadata} making {@code
- * partMetadata.content.toString()} a body content.
- *
- * @param partMetadata {@inheritDoc}
- * @return {@inheritDoc}
- */
- @Override
- public Request.Body encode(PartMetadata partMetadata) {
- return new Part(
- createHeaders(
- partMetadata.name(),
- partMetadata.filenameOverride().orElse(null),
- partMetadata.contentTypeOverride().orElse(null)),
- Request.Body.of(partMetadata.content().orElseThrow().toString()));
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/DelegatingPartEncoder.java b/form/src/main/java/feign/form/multipart/DelegatingPartEncoder.java
deleted file mode 100644
index 30b18d5bb..000000000
--- a/form/src/main/java/feign/form/multipart/DelegatingPartEncoder.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import feign.RequestTemplate;
-import feign.codec.EncodeException;
-import feign.codec.Encoder;
-import java.util.Collection;
-import java.util.Optional;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-
-/**
- * A {@link PartEncoder} that delegates the encoding of the part body to a collection of {@link
- * Encoder}s based on the content type.
- */
-@RequiredArgsConstructor
-public class DelegatingPartEncoder extends AbstractPartEncoder {
- @NonNull private final Collection delegates;
-
- /**
- * Creates a {@link Request.Body} for the given {@link PartMetadata} by delegating the encoding of
- * the part body to the appropriate {@link Encoder} based on the content type.
- *
- * @param partMetadata {@inheritDoc}
- * @return {@inheritDoc}
- * @throws EncodeException {@inheritDoc}
- */
- @Override
- public Request.Body encode(PartMetadata partMetadata) throws EncodeException {
- var contentType = partMetadata.contentTypeOverride().orElseThrow();
-
- return new Part(
- createHeaders(
- partMetadata.name(), partMetadata.filenameOverride().orElse(null), contentType),
- createBody(contentType, partMetadata.content().orElse(null)));
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if there is an {@link Encoder} available for the content type, {@code
- * false} otherwise
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata.contentTypeOverride().flatMap(this::findEncoder).isPresent();
- }
-
- private Optional findEncoder(String contentType) {
- return delegates.stream().filter(encoder -> encoder.supports(contentType)).findFirst();
- }
-
- private Request.Body createBody(String contentType, Object content) throws EncodeException {
- var encoder =
- findEncoder(contentType)
- .orElseThrow(
- () -> new EncodeException("No encoder found for content type: " + contentType));
- var requestTemplate = new RequestTemplate();
-
- encoder.encode(content, content.getClass(), requestTemplate);
-
- return requestTemplate
- .requestBody()
- .orElseThrow(() -> new EncodeException("Body part was not encoded: " + content));
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/EncoderPredicate.java b/form/src/main/java/feign/form/multipart/EncoderPredicate.java
new file mode 100644
index 000000000..5ae0580fe
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/EncoderPredicate.java
@@ -0,0 +1,58 @@
+/*
+ * 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.multipart;
+
+import feign.RequestTemplate;
+import feign.Util;
+import java.lang.reflect.Type;
+import java.util.List;
+import lombok.NonNull;
+
+/** A predicate for determining whether a given object can be encoded. */
+@FunctionalInterface
+public interface EncoderPredicate {
+ /**
+ * Creates an {@link EncoderPredicate} that checks if the request template contains a {@code
+ * Content-Type} header that starts with the given content type.
+ *
+ * @param contentType the case-insensitive content type to check for in the request template's
+ * {@code Content-Type} header
+ * @return an {@link EncoderPredicate} that checks for the specified content type in the request
+ * template's headers
+ */
+ static EncoderPredicate forContentType(@NonNull String contentType) {
+ var expectedContentType = normalizeHeader(contentType);
+
+ return (object, bodyType, template) ->
+ template.headers().getOrDefault(Util.CONTENT_TYPE, List.of()).stream()
+ .anyMatch(header -> normalizeHeader(header).startsWith(expectedContentType));
+ }
+
+ private static String normalizeHeader(String header) {
+ return header.trim().toLowerCase();
+ }
+
+ /**
+ * Determines whether the given object can be encoded.
+ *
+ * @param object the object to be encoded
+ * @param bodyType the type of the object to be encoded
+ * @param template the request template containing the headers and other information about the
+ * request
+ * @return {@code true} if the object can be encoded, {@code false} otherwise
+ */
+ boolean test(Object object, Type bodyType, RequestTemplate template);
+}
diff --git a/form/src/main/java/feign/form/multipart/FilePartContextResolver.java b/form/src/main/java/feign/form/multipart/FilePartContextResolver.java
new file mode 100644
index 000000000..1753aeac3
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/FilePartContextResolver.java
@@ -0,0 +1,44 @@
+/*
+ * 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.multipart;
+
+import java.io.File;
+import java.util.stream.Stream;
+
+/**
+ * A {@link PartContextResolver} implementation that resolves a {@link PartContext} containing a
+ * {@link File} into a {@link PartContext} with the file's path as the content.
+ */
+public class FilePartContextResolver implements PartContextResolver {
+ /**
+ * {@inheritDoc}
+ *
+ * @param partContext {@inheritDoc}
+ * @param chain {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Stream resolve(PartContext partContext, PartContextResolverChain chain) {
+ var unwrappedPartMetadata =
+ partContext
+ .content()
+ .filter(File.class::isInstance)
+ .map(file -> partContext.toBuilder().content(((File) file).toPath()).build())
+ .orElse(partContext);
+
+ return chain.resolve(unwrappedPartMetadata);
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/FilePartEncoder.java b/form/src/main/java/feign/form/multipart/FilePartEncoder.java
deleted file mode 100644
index fe718ad9f..000000000
--- a/form/src/main/java/feign/form/multipart/FilePartEncoder.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import java.io.File;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-
-/** A {@link PartEncoder} that delegates to another {@link PartEncoder} for {@link File} content. */
-@RequiredArgsConstructor
-public class FilePartEncoder extends AbstractPartEncoder {
- @NonNull private final PartEncoder delegate;
-
- /**
- * Creates a {@link Request.Body} from the given {@link PartMetadata} by converting the content to
- * a {@link java.nio.file.Path} and delegating to the provided {@link PartEncoder}.
- *
- * @param partMetadata {@inheritDoc}
- * @return {@inheritDoc}
- */
- @Override
- public Request.Body encode(PartMetadata partMetadata) {
- return delegate.encode(toPathPartMetadata(partMetadata));
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if the content is a {@link File} and the delegate supports it, {@code
- * false} otherwise
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata
- .content()
- .filter(
- content ->
- content instanceof File && delegate.supports(toPathPartMetadata(partMetadata)))
- .isPresent();
- }
-
- private PartMetadata toPathPartMetadata(PartMetadata partMetadata) {
- return new PartMetadata(
- partMetadata.name(),
- partMetadata.content().map(content -> ((File) content).toPath()).orElseThrow(),
- partMetadata.filenameOverride().orElse(null),
- partMetadata.contentTypeOverride().orElse(null));
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/FormDataPartContextResolver.java b/form/src/main/java/feign/form/multipart/FormDataPartContextResolver.java
new file mode 100644
index 000000000..fb5c0221d
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/FormDataPartContextResolver.java
@@ -0,0 +1,52 @@
+/*
+ * 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.multipart;
+
+import java.util.stream.Stream;
+
+/**
+ * A {@link PartContextResolver} implementation that resolves a {@link PartContext} containing a
+ * {@link FormData} into a {@link PartContext} with the unwrapped content of the {@link FormData}.
+ */
+public class FormDataPartContextResolver implements PartContextResolver {
+ /**
+ * {@inheritDoc}
+ *
+ * @param partContext {@inheritDoc}
+ * @param chain {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Stream resolve(PartContext partContext, PartContextResolverChain chain) {
+ var unwrappedPartMetadata =
+ partContext
+ .content()
+ .filter(FormData.class::isInstance)
+ .map(
+ content -> {
+ var formData = (FormData>) content;
+
+ return partContext.toBuilder()
+ .filename(formData.filename().orElse(null))
+ .contentType(formData.contentType().orElse(null))
+ .content(formData.content().orElse(null))
+ .build();
+ })
+ .orElse(partContext);
+
+ return chain.resolve(unwrappedPartMetadata);
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/FormDataPartResolver.java b/form/src/main/java/feign/form/multipart/FormDataPartResolver.java
deleted file mode 100644
index 051099324..000000000
--- a/form/src/main/java/feign/form/multipart/FormDataPartResolver.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import feign.codec.EncodeException;
-import java.util.stream.Stream;
-
-/**
- * A {@link PartResolver} that resolves {@link FormData} into one or more {@link Request.Body}
- * instances.
- */
-public class FormDataPartResolver implements PartResolver {
- /**
- * Resolves the given {@code partMetadata} into one or more {@link Request.Body} instances by
- * delegating to the {@code chain} with a new {@link PartMetadata} created from the given {@code
- * partMetadata} and the content of the {@link FormData}.
- *
- * @param partMetadata {@inheritDoc}
- * @param chain {@inheritDoc}
- * @return {@inheritDoc}
- * @throws EncodeException {@inheritDoc}
- */
- @Override
- public Stream resolve(PartMetadata partMetadata, PartResolverChain chain)
- throws EncodeException {
- var formData = (FormData>) partMetadata.content().orElseThrow();
-
- return chain.resolve(
- new PartMetadata(
- partMetadata.name(),
- formData.content().orElse(null),
- formData.filename().orElse(null),
- formData.contentType().orElse(null)));
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if the content of the given {@code partMetadata} is an instance of {@link
- * FormData}, {@code false} otherwise.
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata.content().filter(FormData.class::isInstance).isPresent();
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/InputStreamPartEncoder.java b/form/src/main/java/feign/form/multipart/InputStreamPartEncoder.java
deleted file mode 100644
index 149b121e0..000000000
--- a/form/src/main/java/feign/form/multipart/InputStreamPartEncoder.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import java.io.InputStream;
-
-/**
- * A {@link PartEncoder} implementation that supports multipart parts with {@link InputStream}
- * content.
- */
-public class InputStreamPartEncoder extends AbstractPartEncoder {
- /**
- * Creates a {@link Request.Body} from the given {@link PartMetadata} making the content of the
- * part an {@link InputStream} body content.
- *
- * @param partMetadata {@inheritDoc}
- * @return {@inheritDoc}
- */
- @Override
- public Request.Body encode(PartMetadata partMetadata) {
- return new Part(
- createHeaders(
- partMetadata.name(),
- partMetadata.filenameOverride().orElse(null),
- partMetadata.contentTypeOverride().orElse(null)),
- new Request.InputStreamBody((InputStream) partMetadata.content().orElseThrow()));
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if the content of the part is an {@link InputStream}, {@code false}
- * otherwise
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata.content().filter(InputStream.class::isInstance).isPresent();
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/IterablePartResolver.java b/form/src/main/java/feign/form/multipart/IterablePartResolver.java
deleted file mode 100644
index 7c073b347..000000000
--- a/form/src/main/java/feign/form/multipart/IterablePartResolver.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import feign.codec.EncodeException;
-import java.nio.file.Path;
-import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
-
-/**
- * A {@link PartResolver} that resolves {@link Iterable} content by delegating to the next resolver
- * in the chain for each item in the {@link Iterable}.
- */
-public class IterablePartResolver implements PartResolver {
- /**
- * Resolves the given {@code partMetadata} by treating its content as an {@link Iterable} and
- * delegating to the {@code chain} with a new {@link PartMetadata} created from the given {@code
- * partMetadata} and each item in the {@link Iterable}.
- *
- * @param partMetadata {@inheritDoc}
- * @param chain {@inheritDoc}
- * @return {@inheritDoc}
- * @throws EncodeException {@inheritDoc}
- */
- @Override
- public Stream resolve(PartMetadata partMetadata, PartResolverChain chain)
- throws EncodeException {
- return StreamSupport.stream(
- ((Iterable>) partMetadata.content().orElseThrow()).spliterator(), false)
- .flatMap(
- item ->
- chain.resolve(
- new PartMetadata(
- partMetadata.name(),
- item,
- partMetadata.filenameOverride().orElse(null),
- partMetadata.contentTypeOverride().orElse(null))));
- }
-
- /**
- * {@inheritDoc}
- *
- * @param partMetadata {@inheritDoc}
- * @return {@code true} if the content of the given {@code partMetadata} is an instance of {@link
- * Iterable} and not an instance of {@link Path}, {@code false} otherwise.
- */
- @Override
- public boolean supports(PartMetadata partMetadata) {
- return partMetadata
- .content()
- .filter(content -> content instanceof Iterable && !(content instanceof Path))
- .isPresent();
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/LeafPartResolver.java b/form/src/main/java/feign/form/multipart/LeafPartResolver.java
deleted file mode 100644
index a8022d97d..000000000
--- a/form/src/main/java/feign/form/multipart/LeafPartResolver.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * 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.multipart;
-
-import feign.Request;
-import feign.codec.EncodeException;
-import java.util.Collection;
-import java.util.stream.Stream;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-
-/**
- * A {@link PartResolver} that resolves a multipart part into a single {@link Request.Body} using
- * the first {@link PartEncoder} that supports the given {@link PartMetadata}.
- */
-@RequiredArgsConstructor
-public class LeafPartResolver implements PartResolver {
- @NonNull private final Collection partEncoders;
-
- /**
- * Resolves the given {@code partMetadata} into a single {@link Request.Body} using the first
- * {@link PartEncoder} that supports the given {@code partMetadata}.
- *
- * @param partMetadata {@inheritDoc}
- * @param chain {@inheritDoc}
- * @return {@inheritDoc}
- * @throws EncodeException {@inheritDoc}
- */
- @Override
- public Stream resolve(PartMetadata partMetadata, PartResolverChain chain)
- throws EncodeException {
- return partEncoders.stream()
- .filter(encoder -> encoder.supports(partMetadata))
- .findFirst()
- .map(encoder -> Stream.of(encoder.encode(partMetadata)))
- .orElseThrow(
- () -> new EncodeException("No part encoder found for a part: " + partMetadata));
- }
-}
diff --git a/form/src/main/java/feign/form/multipart/MultipartFormBodyFactory.java b/form/src/main/java/feign/form/multipart/MultipartFormBodyFactory.java
new file mode 100644
index 000000000..01c6995a5
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/MultipartFormBodyFactory.java
@@ -0,0 +1,55 @@
+/*
+ * 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.multipart;
+
+import feign.codec.EncodeException;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+
+/** A factory for creating {@link MultipartFormBody} instances from a map of form data. */
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class MultipartFormBodyFactory {
+ @NonNull @Builder.Default
+ private final PartContextResolverChain partContextResolverChain = new PartContextResolverChain();
+
+ @NonNull @Builder.Default private final PartFactory partFactory = new PartFactory();
+
+ /**
+ * Creates a {@link MultipartFormBody} from the given form data.
+ *
+ * @param formData the form data to create the multipart form body from
+ * @return a {@link MultipartFormBody} instance representing the given form data
+ * @throws EncodeException if an error occurs while encoding the form data into multipart form
+ * body parts
+ */
+ public MultipartFormBody create(Map formData) throws EncodeException {
+ return formData.entrySet().stream()
+ .flatMap(this::resolve)
+ .map(partFactory::create)
+ .collect(Collectors.collectingAndThen(Collectors.toList(), MultipartFormBody::new));
+ }
+
+ private Stream resolve(Map.Entry entry) {
+ return partContextResolverChain.resolve(new PartContext(entry.getKey(), entry.getValue()));
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/Part.java b/form/src/main/java/feign/form/multipart/Part.java
index 75def13f9..beefce3c6 100644
--- a/form/src/main/java/feign/form/multipart/Part.java
+++ b/form/src/main/java/feign/form/multipart/Part.java
@@ -20,11 +20,11 @@
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
+import java.util.Objects;
import java.util.stream.Collectors;
import lombok.Data;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
-import lombok.experimental.Delegate;
/** A multipart part consisting of headers and a body. */
@Data
@@ -33,8 +33,7 @@ public class Part implements Request.Body {
private static final String CRLF = "\r\n";
@NonNull private final Map headers;
-
- @NonNull @Delegate private final Request.Body body;
+ private final Request.Body body;
/**
* Writes the multipart part to the given output stream. The part is written in the following
@@ -52,7 +51,10 @@ public class Part implements Request.Body {
@Override
public void writeTo(OutputStream outputStream) throws IOException {
outputStream.write(headersToString().getBytes(StandardCharsets.UTF_8));
- body.writeTo(outputStream);
+
+ if (body != null) {
+ body.writeTo(outputStream);
+ }
}
/**
@@ -64,9 +66,14 @@ public void writeTo(OutputStream outputStream) throws IOException {
*/
@Override
public long contentLength() {
- var contentLength = body.contentLength();
+ var bodyLength = body != null ? body.contentLength() : 0;
- return contentLength < 0 ? contentLength : contentLength + headersToString().length();
+ return bodyLength < 0 ? bodyLength : bodyLength + headersToString().length();
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return body == null || body.isRepeatable();
}
/**
@@ -77,7 +84,7 @@ public long contentLength() {
*/
@Override
public String toString() {
- return headersToString() + body;
+ return headersToString() + Objects.requireNonNullElse(body, "");
}
private String headersToString() {
diff --git a/form/src/main/java/feign/form/multipart/PartBodyFactory.java b/form/src/main/java/feign/form/multipart/PartBodyFactory.java
new file mode 100644
index 000000000..b99e2f8e9
--- /dev/null
+++ b/form/src/main/java/feign/form/multipart/PartBodyFactory.java
@@ -0,0 +1,96 @@
+/*
+ * 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.multipart;
+
+import feign.Request;
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+import feign.core.codec.DefaultEncoder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+
+/** A factory for creating part bodies from {@link PartContext} instances. */
+@RequiredArgsConstructor
+public class PartBodyFactory {
+ @NonNull private final Collection partBodyEncoders;
+
+ /** Creates a new {@link PartBodyFactory} with the default list of {@link Encoder} instances. */
+ public PartBodyFactory() {
+ this(defaultEncoders());
+ }
+
+ /**
+ * Creates a new {@link PartBodyFactory} with the customized list of {@link Encoder} instances.
+ *
+ * @param partBodyEncodersCustomizer a {@link Consumer} that can be used to customize the list of
+ * {@link Encoder}
+ */
+ public PartBodyFactory(@NonNull Consumer> partBodyEncodersCustomizer) {
+ this(customEncoders(partBodyEncodersCustomizer));
+ }
+
+ private static List defaultEncoders() {
+ var encoders = new ArrayList();
+
+ encoders.add(new DefaultEncoder());
+
+ return encoders;
+ }
+
+ private static List customEncoders(Consumer> partBodyEncodersCustomizer) {
+ var encoders = defaultEncoders();
+
+ partBodyEncodersCustomizer.accept(encoders);
+
+ return encoders;
+ }
+
+ /**
+ * Creates a new part body from the given headers and part context.
+ *
+ * @param partHeaders the headers for the part
+ * @param partContext the context for the part, which may contain the content to be encoded into
+ * the body
+ * @return the created part body
+ * @throws EncodeException if no suitable encoder is found for the content in the part context
+ */
+ public Request.Body create(Map partHeaders, PartContext partContext)
+ throws EncodeException {
+ var content = partContext.content().orElse(null);
+ var bodyType = content != null ? content.getClass() : Void.class;
+
+ for (var encoder : partBodyEncoders) {
+ var template = new RequestTemplate();
+
+ partHeaders.forEach(template::headerLiteral);
+
+ try {
+ encoder.encode(content, bodyType, template);
+
+ return template.requestBody().orElse(null);
+ } catch (EncodeException ignored) {
+ }
+ }
+
+ throw new EncodeException("No suitable part body encoder found for object: " + content);
+ }
+}
diff --git a/form/src/main/java/feign/form/multipart/PartMetadata.java b/form/src/main/java/feign/form/multipart/PartContext.java
similarity index 63%
rename from form/src/main/java/feign/form/multipart/PartMetadata.java
rename to form/src/main/java/feign/form/multipart/PartContext.java
index d626a2cea..1f7aa7f2a 100644
--- a/form/src/main/java/feign/form/multipart/PartMetadata.java
+++ b/form/src/main/java/feign/form/multipart/PartContext.java
@@ -16,16 +16,18 @@
package feign.form.multipart;
import java.util.Optional;
+import lombok.Builder;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.experimental.Accessors;
-/** Metadata for a single multipart part. */
+/** A context for a multipart part. */
@Value
@RequiredArgsConstructor
@Accessors(fluent = true)
-public class PartMetadata {
+@Builder(toBuilder = true)
+public class PartContext {
/** Form field name; never {@code null}. */
@NonNull String name;
@@ -33,19 +35,18 @@ public class PartMetadata {
Object content;
/** Optional filename written into {@code Content-Disposition: ...; filename=""}. */
- String filenameOverride;
+ String filename;
/** Optional media type written as {@code Content-Type: }. */
- String contentTypeOverride;
+ String contentType;
/**
- * Constructs a new {@link PartMetadata} with the given name and content, and no filename or
- * content type overrides.
+ * Constructs a new {@link PartContext} with the given name and content.
*
* @param name form field name; must not be {@code null}
* @param content content of the part; may be {@code null}
*/
- public PartMetadata(String name, Object content) {
+ public PartContext(String name, Object content) {
this(name, content, null, null);
}
@@ -60,22 +61,22 @@ public Optional