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 content() { } /** - * Returns the filename override of the part as an {@link Optional}. + * Returns the filename of the part as an {@link Optional}. * - * @return an {@link Optional} containing the filename override of the part, or an empty {@link - * Optional} if the filename override is {@code null} + * @return an {@link Optional} containing the filename of the part, or an empty {@link Optional} + * if the filename is {@code null} */ - public Optional filenameOverride() { - return Optional.ofNullable(filenameOverride); + public Optional filename() { + return Optional.ofNullable(filename); } /** - * Returns the content type override of the part as an {@link Optional}. + * Returns the content type of the part as an {@link Optional}. * - * @return an {@link Optional} containing the content type override of the part, or an empty - * {@link Optional} if the content type override is {@code null} + * @return an {@link Optional} containing the content type of the part, or an empty {@link + * Optional} if the content type is {@code null} */ - public Optional contentTypeOverride() { - return Optional.ofNullable(contentTypeOverride); + public Optional contentType() { + return Optional.ofNullable(contentType); } } diff --git a/api/src/main/java/feign/codec/XmlEncoder.java b/form/src/main/java/feign/form/multipart/PartContextResolver.java similarity index 52% rename from api/src/main/java/feign/codec/XmlEncoder.java rename to form/src/main/java/feign/form/multipart/PartContextResolver.java index 73209b0d8..666ccecb9 100644 --- a/api/src/main/java/feign/codec/XmlEncoder.java +++ b/form/src/main/java/feign/form/multipart/PartContextResolver.java @@ -13,19 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feign.codec; +package feign.form.multipart; -/** An {@link Encoder} that encodes request bodies as XML. */ +import java.util.stream.Stream; + +/** + * A functional interface for resolving a {@link PartContext} into a stream of {@link PartContext} + * instances. + */ @FunctionalInterface -public interface XmlEncoder extends Encoder { +public interface PartContextResolver { /** - * {@inheritDoc} + * Resolves a {@link PartContext} into a stream of {@link PartContext} instances. * - * @param contentType {@inheritDoc} - * @return {@code true} if the given {@code contentType} is an XML media type, {@code false} + * @param partContext the {@link PartContext} to resolve + * @param chain the {@link PartContextResolverChain} to use for resolving the {@link PartContext} + * @return a stream of {@link PartContext} instances */ - @Override - default boolean supports(String contentType) { - return contentType != null && contentType.trim().matches("(?i)\\w+/(?:[\\w._-]+\\+)?xml.*"); - } + Stream resolve(PartContext partContext, PartContextResolverChain chain); } diff --git a/form/src/main/java/feign/form/multipart/PartContextResolverChain.java b/form/src/main/java/feign/form/multipart/PartContextResolverChain.java new file mode 100644 index 000000000..f5c6dbbd8 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PartContextResolverChain.java @@ -0,0 +1,98 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +/** + * A chain of {@link PartContextResolver} instances that can be used to resolve a {@link + * PartContext} into a stream of {@link PartContext} instances. + */ +@RequiredArgsConstructor +public class PartContextResolverChain { + @NonNull private final List resolvers; + private final int index; + + public PartContextResolverChain() { + this(defaultResolvers()); + } + + /** + * Creates a new {@link PartContextResolverChain} with the given list of {@link + * PartContextResolver} instances. + * + * @param resolvers the list of {@link PartContextResolver} instances to use for resolving {@link + * PartContext} instances + */ + public PartContextResolverChain(List resolvers) { + this(resolvers, 0); + } + + /** + * Creates a new {@link PartContextResolverChain} with the customized list of {@link + * PartContextResolver} instances. + * + * @param resolversCustomizer a {@link Consumer} that can be used to customize the list of {@link + * PartContextResolver} + */ + public PartContextResolverChain( + @NonNull Consumer> resolversCustomizer) { + this(customResolvers(resolversCustomizer)); + } + + private static List defaultResolvers() { + var resolvers = new ArrayList(); + + resolvers.add(new FormDataPartContextResolver()); + resolvers.add(new ArrayPartContextResolver()); + resolvers.add(new CollectionPartContextResolver()); + resolvers.add(new FilePartContextResolver()); + resolvers.add(new PathPartContextResolver()); + + return resolvers; + } + + private static List customResolvers( + Consumer> resolversCustomizer) { + var resolvers = defaultResolvers(); + + resolversCustomizer.accept(resolvers); + + return resolvers; + } + + /** + * Resolves the given {@link PartContext} using the chain of {@link PartContextResolver} + * instances. + * + * @param partContext the {@link PartContext} to resolve + * @return a stream of resolved {@link PartContext} instances + */ + public Stream resolve(PartContext partContext) { + if (index >= resolvers.size()) { + return Stream.of(partContext); + } + + return resolvers + .get(index) + .resolve(partContext, new PartContextResolverChain(resolvers, index + 1)); + } +} diff --git a/form/src/main/java/feign/form/multipart/PartEncoder.java b/form/src/main/java/feign/form/multipart/PartEncoder.java deleted file mode 100644 index 2ebdda29b..000000000 --- a/form/src/main/java/feign/form/multipart/PartEncoder.java +++ /dev/null @@ -1,49 +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; - -/** - * A strategy that encodes a {@link PartMetadata} into a {@link Request.Body}. It's recommended to - * use {@link AbstractPartEncoder} as a base class for custom implementations. - * - * @see AbstractPartEncoder - */ -@FunctionalInterface -public interface PartEncoder { - /** - * Encodes the given {@link PartMetadata} into a {@link Request.Body}. - * - * @param partMetadata the metadata of the part to encode - * @return the encoded part as a {@link Request.Body} - * @throws EncodeException if encoding fails - */ - Request.Body encode(PartMetadata partMetadata) throws EncodeException; - - /** - * Determines whether this encoder supports encoding the given {@link PartMetadata}. By default, - * it returns {@code true}. - * - * @param partMetadata the metadata of the part to check for support - * @return {@code true} if this encoder supports encoding the given part metadata, {@code false} - * otherwise - */ - default boolean supports(PartMetadata partMetadata) { - return true; - } -} diff --git a/form/src/main/java/feign/form/multipart/PartFactory.java b/form/src/main/java/feign/form/multipart/PartFactory.java new file mode 100644 index 000000000..7ecaf8f81 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PartFactory.java @@ -0,0 +1,48 @@ +/* + * 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 lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +/** A factory for creating multipart form data parts from {@link PartContext} instances. */ +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PartFactory { + @NonNull @Builder.Default + private final PartHeadersFactory partHeadersFactory = new PartHeadersFactory(); + + @NonNull @Builder.Default private final PartBodyFactory partBodyFactory = new PartBodyFactory(); + + /** + * Creates a multipart form data part from the given {@link PartContext}. + * + * @param partContext the {@link PartContext} to create the multipart form data part from + * @return the created multipart form data part + * @throws EncodeException if an error occurs while encoding the part body from the part context + */ + public Request.Body create(PartContext partContext) throws EncodeException { + var headers = partHeadersFactory.create(partContext); + var body = partBodyFactory.create(headers, partContext); + + return new Part(headers, body); + } +} diff --git a/form/src/main/java/feign/form/multipart/AbstractPartEncoder.java b/form/src/main/java/feign/form/multipart/PartHeadersFactory.java similarity index 57% rename from form/src/main/java/feign/form/multipart/AbstractPartEncoder.java rename to form/src/main/java/feign/form/multipart/PartHeadersFactory.java index ad6d85f9a..73a47b0ca 100644 --- a/form/src/main/java/feign/form/multipart/AbstractPartEncoder.java +++ b/form/src/main/java/feign/form/multipart/PartHeadersFactory.java @@ -19,59 +19,51 @@ import java.util.LinkedHashMap; import java.util.Map; -/** - * Base class for {@link PartEncoder} implementations that provides common functionality for - * creating headers for multipart form data parts. - */ -public abstract class AbstractPartEncoder implements PartEncoder { +/** A factory for creating part headers based on a {@link PartContext}. */ +public class PartHeadersFactory { private static final char DOUBLE_QUOTE = '"'; /** - * Creates headers for a multipart form data part based on the provided name, filename, and - * content type. + * Escapes a {@code multipart/form-data} header parameter value so an attacker-supplied name or + * file name cannot break out of the quoted string and inject extra headers or part boundaries. + * Carriage return, line feed and double quote are percent-encoded, matching the WHATWG form-data + * encoding rules. + * + * @param value the raw parameter value. + * @return the escaped value, safe to place inside a quoted header parameter. + */ + private static String escapeHeaderParameter(String value) { + return value.replace("\r", "%0D").replace("\n", "%0A").replace("\"", "%22"); + } + + /** + * Creates part headers based on the given {@link PartContext}. * - * @param name the name of the form field - * @param filename the optional filename to include in the {@code Content-Disposition} header; may - * be {@code null} - * @param contentType the optional content type to include in the {@code Content-Type} header; may - * be {@code null} - * @return a map of headers for the multipart form data part + * @param partContext the context to create part headers from + * @return a map of part headers */ - protected Map createHeaders(String name, String filename, String contentType) { + public Map create(PartContext partContext) { var headers = new LinkedHashMap(); var disposition = new StringBuilder("form-data; name=") .append(DOUBLE_QUOTE) - .append(escapeHeaderParameter(name)) + .append(escapeHeaderParameter(partContext.name())) .append(DOUBLE_QUOTE); - if (filename != null) { - disposition - .append("; filename=") - .append(DOUBLE_QUOTE) - .append(escapeHeaderParameter(filename)) - .append(DOUBLE_QUOTE); - } + partContext + .filename() + .ifPresent( + filename -> + disposition + .append("; filename=") + .append(DOUBLE_QUOTE) + .append(escapeHeaderParameter(filename)) + .append(DOUBLE_QUOTE)); headers.put("Content-Disposition", disposition.toString()); - if (contentType != null) { - headers.put(Util.CONTENT_TYPE, contentType); - } + partContext.contentType().ifPresent(contentType -> headers.put(Util.CONTENT_TYPE, contentType)); return headers; } - - /** - * Escapes a {@code multipart/form-data} header parameter value so an attacker-supplied name or - * file name cannot break out of the quoted string and inject extra headers or part boundaries. - * Carriage return, line feed and double quote are percent-encoded, matching the WHATWG form-data - * encoding rules. - * - * @param value the raw parameter value. - * @return the escaped value, safe to place inside a quoted header parameter. - */ - protected static String escapeHeaderParameter(String value) { - return value.replace("\r", "%0D").replace("\n", "%0A").replace("\"", "%22"); - } } diff --git a/form/src/main/java/feign/form/multipart/PartResolver.java b/form/src/main/java/feign/form/multipart/PartResolver.java deleted file mode 100644 index 49222e7bd..000000000 --- a/form/src/main/java/feign/form/multipart/PartResolver.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; -import feign.codec.EncodeException; -import java.util.stream.Stream; - -/** - * A strategy for resolving {@link PartMetadata} into one or more {@link Request.Body} instances. - */ -@FunctionalInterface -public interface PartResolver { - /** - * Resolves the given {@link PartMetadata} into one or more {@link Request.Body} instances. - * - * @param partMetadata the metadata of the part to resolve - * @param chain the chain of resolvers to delegate to if this resolver cannot handle the part - * @return a stream of {@link Request.Body} instances representing the resolved part - * @throws EncodeException if an error occurs during encoding the part - */ - Stream resolve(PartMetadata partMetadata, PartResolverChain chain) - throws EncodeException; - - /** - * Determines whether this resolver supports the given {@link PartMetadata}. By default, it - * returns {@code true}. - * - * @param partMetadata the metadata of the part to check for support - * @return {@code true} if this resolver supports the given part metadata, {@code false} otherwise - */ - default boolean supports(PartMetadata partMetadata) { - return true; - } -} diff --git a/form/src/main/java/feign/form/multipart/PartResolverChain.java b/form/src/main/java/feign/form/multipart/PartResolverChain.java deleted file mode 100644 index c399335cb..000000000 --- a/form/src/main/java/feign/form/multipart/PartResolverChain.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 chain of {@link PartResolver} instances that can resolve a {@link PartMetadata} into one or - * more {@link Request.Body} instances. - */ -@RequiredArgsConstructor -public class PartResolverChain { - @NonNull private final Collection partResolvers; - - /** - * Resolves the given {@link PartMetadata} into one or more {@link Request.Body} instances by - * delegating to the first {@link PartResolver} that supports the given {@code partMetadata}. - * - * @param partMetadata the metadata of the part to resolve - * @return a stream of {@link Request.Body} instances representing the resolved part - * @throws EncodeException if an error occurs during resolving/encoding the part - */ - public Stream resolve(PartMetadata partMetadata) throws EncodeException { - if (partMetadata.content().isEmpty()) { - return Stream.empty(); - } - - return partResolvers.stream() - .filter(resolver -> resolver.supports(partMetadata)) - .findFirst() - .map(resolver -> resolver.resolve(partMetadata, this)) - .orElseThrow(() -> new EncodeException("No resolver found for part: " + partMetadata)); - } -} diff --git a/form/src/main/java/feign/form/multipart/PathPartContextResolver.java b/form/src/main/java/feign/form/multipart/PathPartContextResolver.java new file mode 100644 index 000000000..9d85a3c3f --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PathPartContextResolver.java @@ -0,0 +1,82 @@ +/* + * 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.net.URLConnection; +import java.nio.file.Path; +import java.util.stream.Stream; + +/** + * A {@link PartContextResolver} implementation that resolves a {@link PartContext} containing a + * {@link Path} into a {@link PartContext} with the file's name and content type as metadata. + */ +public class PathPartContextResolver implements PartContextResolver { + private static String resolveFilename(PartContext partContext, Path path) { + return partContext + .filename() + .orElseGet( + () -> { + var filename = path.getFileName(); + + return filename != null ? filename.toString() : null; + }); + } + + private static String resolveContentType(PartContext partContext, String filename) { + return partContext.contentType().orElseGet(() -> guessContentTypeFromName(filename)); + } + + private static String guessContentTypeFromName(String filename) { + if (filename != null) { + var guessedContentType = URLConnection.guessContentTypeFromName(filename); + + if (guessedContentType != null) { + return guessedContentType; + } + } + + return "application/octet-stream"; + } + + /** + * {@inheritDoc} + * + * @param partContext {@inheritDoc} + * @param chain {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public Stream resolve(PartContext partContext, PartContextResolverChain chain) { + var unwrappedPartMetadata = + partContext + .content() + .filter(Path.class::isInstance) + .map( + content -> { + var path = (Path) content; + var filename = resolveFilename(partContext, path); + var contentType = resolveContentType(partContext, filename); + + return partContext.toBuilder() + .filename(filename) + .contentType(contentType) + .build(); + }) + .orElse(partContext); + + return chain.resolve(unwrappedPartMetadata); + } +} diff --git a/form/src/main/java/feign/form/multipart/PathPartEncoder.java b/form/src/main/java/feign/form/multipart/PathPartEncoder.java deleted file mode 100644 index ee6c02b66..000000000 --- a/form/src/main/java/feign/form/multipart/PathPartEncoder.java +++ /dev/null @@ -1,73 +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.net.URLConnection; -import java.nio.file.Path; - -/** Encodes a {@link Path} as a multipart part. */ -public class PathPartEncoder extends AbstractPartEncoder { - /** - * Encodes the given {@link PartMetadata} into a {@link Request.Body}. - * - * @param partMetadata {@inheritDoc} - * @return {@inheritDoc} - */ - @Override - public Request.Body encode(PartMetadata partMetadata) { - var path = (Path) partMetadata.content().orElseThrow(); - var filename = - partMetadata - .filenameOverride() - .orElseGet( - () -> { - var fileName = path.getFileName(); - - return fileName != null ? fileName.toString() : null; - }); - var contentType = - partMetadata - .contentTypeOverride() - .orElseGet( - () -> { - if (filename != null) { - var guessedContentType = URLConnection.guessContentTypeFromName(filename); - - if (guessedContentType != null) { - return guessedContentType; - } - } - - return "application/octet-stream"; - }); - - return new Part( - createHeaders(partMetadata.name(), filename, contentType), new Request.PathBody(path)); - } - - /** - * {@inheritDoc} - * - * @param partMetadata {@inheritDoc} - * @return {@code true} if the content of the given {@code partMetadata} is an instance of {@link - * Path}, {@code false} otherwise. - */ - @Override - public boolean supports(PartMetadata partMetadata) { - return partMetadata.content().filter(Path.class::isInstance).isPresent(); - } -} diff --git a/form/src/test/java/feign/form/StreamingMultipartFormTest.java b/form/src/test/java/feign/form/StreamingMultipartFormTest.java index 0368b39e8..06fc15e0a 100644 --- a/form/src/test/java/feign/form/StreamingMultipartFormTest.java +++ b/form/src/test/java/feign/form/StreamingMultipartFormTest.java @@ -34,7 +34,12 @@ import feign.Param; import feign.RequestLine; import feign.Util; +import feign.form.multipart.ConditionalEncoder; +import feign.form.multipart.EncoderPredicate; import feign.form.multipart.FormData; +import feign.form.multipart.MultipartFormBodyFactory; +import feign.form.multipart.PartBodyFactory; +import feign.form.multipart.PartFactory; import feign.jackson.JacksonEncoder; import java.io.ByteArrayInputStream; import java.io.File; @@ -43,6 +48,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collection; import java.util.List; import lombok.Cleanup; import org.junit.jupiter.api.BeforeEach; @@ -57,17 +63,27 @@ public class StreamingMultipartFormTest { private static final String MULTIPART_FORM_DATA_HEADER = Util.CONTENT_TYPE + ": " + MediaType.MULTIPART_FORM_DATA_VALUE; private static final String PARAM_NAME = "data"; + private static final String CLASSNAME = StreamingMultipartFormTest.class.getSimpleName(); private MultipartFormTestClient testClient; @BeforeEach public void setup(WireMockRuntimeInfo wmRuntimeInfo) { + var partBodyFactory = + new PartBodyFactory( + encoders -> + encoders.addFirst( + new ConditionalEncoder( + new JacksonEncoder(), + EncoderPredicate.forContentType(MediaType.APPLICATION_JSON_VALUE)))); + var partFactory = PartFactory.builder().partBodyFactory(partBodyFactory).build(); + var multipartFormBodyFactory = + MultipartFormBodyFactory.builder().partFactory(partFactory).build(); + var multipartFormEncoder = + MultipartFormEncoder.builder().multipartFormBodyFactory(multipartFormBodyFactory).build(); testClient = Feign.builder() - .encoder( - MultipartFormEncoder.builder() - .partBodyEncoders(List.of(new JacksonEncoder())) - .build()) + .encoder(multipartFormEncoder) .target(MultipartFormTestClient.class, wmRuntimeInfo.getHttpBaseUrl()); stubFor(post(REQUEST_PATH).willReturn(ok())); @@ -145,8 +161,7 @@ void shouldSendJson() { @Test void shouldSendFile(@TempDir File tempDir) throws IOException { var expected = "Hello, World!"; - var file = - new File(tempDir, StreamingMultipartFormTest.class.getSimpleName() + "_shouldSendFile.txt"); + var file = new File(tempDir, CLASSNAME + "_shouldSendFile.txt"); Files.writeString(file.toPath(), expected); @@ -167,10 +182,7 @@ void shouldSendFile(@TempDir File tempDir) throws IOException { @Test void shouldSendOctetStreamFile(@TempDir File tempDir) throws IOException { var expected = "Hello, World!"; - var file = - new File( - tempDir, - StreamingMultipartFormTest.class.getSimpleName() + "_shouldSendOctetStreamFile"); + var file = new File(tempDir, CLASSNAME + "_shouldSendOctetStreamFile"); Files.writeString(file.toPath(), expected); @@ -193,14 +205,9 @@ Util.CONTENT_TYPE, equalTo(MediaType.APPLICATION_OCTET_STREAM_VALUE)) void shouldSendFileWithOverriddenFilenameAndContentType(@TempDir File tempDir) throws IOException { var expected = "Hello, World!"; - var filename = - StreamingMultipartFormTest.class.getSimpleName() - + "_shouldSendFileWithOverriddenFilenameAndContentType.txt"; + var filename = CLASSNAME + "_shouldSendFileWithOverriddenFilenameAndContentType.txt"; var file = - new File( - tempDir, - StreamingMultipartFormTest.class.getSimpleName() - + "_shouldSendFileWithOverriddenFilenameAndContentType.md"); + new File(tempDir, CLASSNAME + "_shouldSendFileWithOverriddenFilenameAndContentType.md"); Files.writeString(file.toPath(), expected); @@ -240,7 +247,7 @@ void shouldSendStringList() { var catwoman = "Catwoman"; var villains = List.of(joker, harleyQuinn, catwoman); - testClient.sendStringIterable(villains); + testClient.sendStringCollection(villains); verify( postRequestedFor(urlPathEqualTo(REQUEST_PATH)) @@ -255,8 +262,7 @@ void shouldSendStringList() { @Test void shouldSendPath(@TempDir Path tempDir) throws IOException { var expected = "Hello, World!"; - var path = - tempDir.resolve(StreamingMultipartFormTest.class.getSimpleName() + "_shouldSendPath.txt"); + var path = tempDir.resolve(CLASSNAME + "_shouldSendPath.txt"); Files.writeString(path, expected); @@ -274,6 +280,121 @@ void shouldSendPath(@TempDir Path tempDir) throws IOException { .build())); } + @Test + void shouldSendStringListFormData() { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var catwoman = "Catwoman"; + var villains = List.of(joker, harleyQuinn, catwoman); + var contentType = MediaType.TEXT_PLAIN_VALUE; + + testClient.sendStringListFormData(new FormData<>(villains).contentType(contentType)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(Util.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(joker)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(harleyQuinn)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(catwoman)) + .build())); + } + + @Test + void shouldSendFileList(@TempDir File tempDir) throws IOException { + var fileContent1 = "FileContent1"; + var fileContent2 = "FileContent2"; + var file1 = new File(tempDir, CLASSNAME + "_shouldSendFileList_file1.txt"); + var file2 = new File(tempDir, CLASSNAME + "_shouldSendFileList_file2.txt"); + var contentType = MediaType.TEXT_PLAIN_VALUE; + + Files.writeString(file1.toPath(), fileContent1); + Files.writeString(file2.toPath(), fileContent2); + + testClient.sendFileList(List.of(file1, file2)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(Util.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file1.getName()) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(fileContent1)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file2.getName()) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(fileContent2)) + .build())); + } + + @Test + void shouldSendFileListFormData(@TempDir File tempDir) throws IOException { + var fileContent1 = "FileContent1"; + var fileContent2 = "FileContent2"; + var file1 = new File(tempDir, CLASSNAME + "_shouldSendFileListFormData_file1.txt"); + var file2 = new File(tempDir, CLASSNAME + "_shouldSendFileListFormData_file2.txt"); + var contentType = MediaType.TEXT_MARKDOWN_VALUE; + + Files.writeString(file1.toPath(), fileContent1); + Files.writeString(file2.toPath(), fileContent2); + + testClient.sendFileListFormData(new FormData<>(List.of(file1, file2)).contentType(contentType)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(Util.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file1.getName()) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(fileContent1)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file2.getName()) + .withHeader(Util.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(fileContent2)) + .build())); + } + + @Test + void shouldEscapeNameAndFilenameInjectedCharacters() { + var input = "Hello, World!"; + var filename = "evil\"\r\nX-Injected: 1"; + + testClient.sendInjectedParamName(new FormData<>(input).filename(filename)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(Util.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName("a%22%0D%0AX-Injected: 1") + .withFileName("evil%22%0D%0AX-Injected: 1") + .withBody(equalTo(input)) + .build())); + } + private interface MultipartFormTestClient { @RequestLine(REQUEST_LINE) @Headers(MULTIPART_FORM_DATA_HEADER) @@ -305,11 +426,27 @@ private interface MultipartFormTestClient { @RequestLine(REQUEST_LINE) @Headers(MULTIPART_FORM_DATA_HEADER) - void sendStringIterable(@Param(PARAM_NAME) Iterable data); + void sendStringCollection(@Param(PARAM_NAME) Collection data); @RequestLine(REQUEST_LINE) @Headers(MULTIPART_FORM_DATA_HEADER) void sendPath(@Param(PARAM_NAME) Path data); + + @RequestLine(REQUEST_LINE) + @Headers(MULTIPART_FORM_DATA_HEADER) + void sendStringListFormData(@Param(PARAM_NAME) FormData> data); + + @RequestLine(REQUEST_LINE) + @Headers(MULTIPART_FORM_DATA_HEADER) + void sendFileList(@Param(PARAM_NAME) List data); + + @RequestLine(REQUEST_LINE) + @Headers(MULTIPART_FORM_DATA_HEADER) + void sendFileListFormData(@Param(PARAM_NAME) FormData> data); + + @RequestLine(REQUEST_LINE) + @Headers(MULTIPART_FORM_DATA_HEADER) + void sendInjectedParamName(@Param("a\"\r\nX-Injected: 1") FormData data); } private record Movie( diff --git a/form/src/test/java/feign/form/multipart/AbstractPartEncoderTest.java b/form/src/test/java/feign/form/multipart/AbstractPartEncoderTest.java deleted file mode 100644 index 019506b4c..000000000 --- a/form/src/test/java/feign/form/multipart/AbstractPartEncoderTest.java +++ /dev/null @@ -1,43 +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 static java.nio.charset.StandardCharsets.UTF_8; -import static org.assertj.core.api.Assertions.assertThat; - -import feign.Request; -import org.junit.jupiter.api.Test; - -class AbstractPartEncoderTest { - - @Test - void nameAndFileNameWithCrlfAndQuoteAreEscaped() { - PartMetadata metadata = - new PartMetadata( - "a\"\r\nX-Injected: 1", - "body".getBytes(UTF_8), - "evil\"\r\nX-Injected: 1", - "text/plain"); - - Request.Body body = new ByteArrayPartEncoder().encode(metadata); - String disposition = ((Part) body).getHeaders().get("Content-Disposition"); - - assertThat(disposition) - .isEqualTo( - "form-data; name=\"a%22%0D%0AX-Injected: 1\"; filename=\"evil%22%0D%0AX-Injected: 1\""); - assertThat(disposition).doesNotContain("\r\nX-Injected: 1"); - } -} diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java index a49f64d29..6e4caeaed 100644 --- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java @@ -23,12 +23,12 @@ import feign.Request; import feign.RequestTemplate; import feign.codec.EncodeException; -import feign.codec.JsonEncoder; +import feign.codec.Encoder; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Type; -public final class JacksonJaxbJsonEncoder implements JsonEncoder { +public final class JacksonJaxbJsonEncoder implements Encoder { private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider; public JacksonJaxbJsonEncoder() { diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java index 2104ab056..26fe78179 100644 --- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java +++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java @@ -21,12 +21,11 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; -import feign.codec.JsonEncoder; import java.io.IOException; import java.lang.reflect.Type; /** A {@link Encoder} that uses Jackson Jr to convert objects to String or byte representation. */ -public class JacksonJrEncoder extends JacksonJrMapper implements JsonEncoder { +public class JacksonJrEncoder extends JacksonJrMapper implements Encoder { public JacksonJrEncoder() { super(); diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java index bc7347986..a25845800 100644 --- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java @@ -18,7 +18,7 @@ import feign.Request; import feign.RequestTemplate; import feign.codec.EncodeException; -import feign.codec.XmlEncoder; +import feign.codec.Encoder; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; import java.io.StringWriter; @@ -43,7 +43,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class JAXBEncoder implements XmlEncoder { +public class JAXBEncoder implements Encoder { private final JAXBContextFactory jaxbContextFactory; diff --git a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java index e71b1b695..4c05d82e7 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -18,7 +18,7 @@ import feign.Request; import feign.RequestTemplate; import feign.codec.EncodeException; -import feign.codec.XmlEncoder; +import feign.codec.Encoder; import java.io.StringWriter; import java.lang.reflect.Type; import javax.xml.bind.JAXBException; @@ -43,7 +43,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class JAXBEncoder implements XmlEncoder { +public class JAXBEncoder implements Encoder { private final JAXBContextFactory jaxbContextFactory; diff --git a/json/src/main/java/feign/json/JsonEncoder.java b/json/src/main/java/feign/json/JsonEncoder.java index 7e77053b7..1ef3cd6fb 100644 --- a/json/src/main/java/feign/json/JsonEncoder.java +++ b/json/src/main/java/feign/json/JsonEncoder.java @@ -20,6 +20,7 @@ import feign.Request; import feign.RequestTemplate; import feign.codec.EncodeException; +import feign.codec.Encoder; import java.lang.reflect.Type; import org.json.JSONArray; import org.json.JSONObject; @@ -51,7 +52,7 @@ * github.create("openfeign", "feign", contributor); * */ -public class JsonEncoder implements feign.codec.JsonEncoder { +public class JsonEncoder implements Encoder { @Override public void encode(Object object, Type bodyType, RequestTemplate template) diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java index 7f77d00d0..31bc76830 100644 --- a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java +++ b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java @@ -18,7 +18,7 @@ import feign.Request; import feign.RequestTemplate; import feign.codec.EncodeException; -import feign.codec.XmlEncoder; +import feign.codec.Encoder; import feign.jaxb.JAXBContextFactory; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; @@ -83,7 +83,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class SOAPEncoder implements XmlEncoder { +public class SOAPEncoder implements Encoder { private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL; diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java index df0ecaf34..711c44171 100644 --- a/soap/src/main/java/feign/soap/SOAPEncoder.java +++ b/soap/src/main/java/feign/soap/SOAPEncoder.java @@ -18,7 +18,7 @@ import feign.Request; import feign.RequestTemplate; import feign.codec.EncodeException; -import feign.codec.XmlEncoder; +import feign.codec.Encoder; import feign.jaxb.JAXBContextFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -83,7 +83,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class SOAPEncoder implements XmlEncoder { +public class SOAPEncoder implements Encoder { private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL;