From 768c393504507d1a5081cc8976ee57fb11594683 Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Wed, 24 Jun 2026 21:17:45 +0300 Subject: [PATCH 1/5] feat: add `@Part` annotation for multipart form-data request contracts Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> --- api/pom.xml | 12 + api/src/main/java/feign/Contract.java | 7 + api/src/main/java/feign/MethodMetadata.java | 11 + .../main/java/feign/MultipartFormData.java | 93 +++++++ api/src/main/java/feign/Part.java | 70 ++++++ api/src/main/java/feign/PartData.java | 136 ++++++++++ api/src/main/java/feign/PartMetadata.java | 129 ++++++++++ .../feign/RequestTemplateFactoryResolver.java | 46 +++- .../BuildMultipartTemplateFromArgsTest.java | 121 +++++++++ .../main/java/feign/core/DefaultContract.java | 40 +++ .../core/DefaultContractMultipartTest.java | 233 ++++++++++++++++++ 11 files changed, 897 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/feign/MultipartFormData.java create mode 100644 api/src/main/java/feign/Part.java create mode 100644 api/src/main/java/feign/PartData.java create mode 100644 api/src/main/java/feign/PartMetadata.java create mode 100644 api/src/test/java/feign/BuildMultipartTemplateFromArgsTest.java create mode 100644 core/src/test/java/feign/core/DefaultContractMultipartTest.java diff --git a/api/pom.xml b/api/pom.xml index 48dce106da..01f6ca427b 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -42,6 +42,18 @@ ${mockito.version} test + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + org.assertj + assertj-core + test + com.squareup.okhttp3 diff --git a/api/src/main/java/feign/Contract.java b/api/src/main/java/feign/Contract.java index dc040918a3..90d7461e9f 100644 --- a/api/src/main/java/feign/Contract.java +++ b/api/src/main/java/feign/Contract.java @@ -171,6 +171,13 @@ protected MethodMetadata parseAndValidateMetadata(Class targetType, Method me } } + if (!data.partMetadata().isEmpty()) { + checkState( + data.bodyIndex() == null, + "Body parameters cannot be used with @Part parameters.%s", + data.warnings()); + } + return data; } diff --git a/api/src/main/java/feign/MethodMetadata.java b/api/src/main/java/feign/MethodMetadata.java index 4cf258c7cd..ab1d9eba75 100644 --- a/api/src/main/java/feign/MethodMetadata.java +++ b/api/src/main/java/feign/MethodMetadata.java @@ -41,6 +41,7 @@ public final class MethodMetadata implements Serializable { private final Map> indexToExpanderClass = new LinkedHashMap>(); private final Map indexToEncoded = new LinkedHashMap(); + private final Map partMetadata = new LinkedHashMap<>(); private transient Map indexToExpander; private BitSet parameterToIgnore = new BitSet(); private boolean ignored; @@ -159,6 +160,15 @@ public Map indexToEncoded() { return indexToEncoded; } + /** + * Returns metadata for {@link Part}-annotated parameters, keyed by parameter index. + * + * @return metadata for {@link Part}-annotated parameters, keyed by parameter index + */ + public Map partMetadata() { + return partMetadata; + } + /** If {@link #indexToExpander} is null, classes here will be instantiated by newInstance. */ public Map> indexToExpanderClass() { return indexToExpanderClass; @@ -217,6 +227,7 @@ public boolean isAlreadyProcessed(Integer index) { || indexToName.containsKey(index) || indexToExpanderClass.containsKey(index) || indexToEncoded.containsKey(index) + || partMetadata.containsKey(index) || (indexToExpander != null && indexToExpander.containsKey(index)) || parameterToIgnore.get(index); } diff --git a/api/src/main/java/feign/MultipartFormData.java b/api/src/main/java/feign/MultipartFormData.java new file mode 100644 index 0000000000..93e7a8ddee --- /dev/null +++ b/api/src/main/java/feign/MultipartFormData.java @@ -0,0 +1,93 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A container for a list of resolved multipart parts and the invocation-time parameter map, passed + * to the encoder for header resolution and wire-format serialization. + */ +public class MultipartFormData { + private final List parts; + private final Map variables; + + /** + * Creates a multipart form body from a list of parts and a parameter map. + * + * @param parts list of multipart parts + * @param variables invocation-time parameter name to value map, used by the encoder to resolve + * {@code {name}} placeholders in part headers + */ + public MultipartFormData(List parts, Map variables) { + this.parts = Objects.requireNonNull(parts, "parts must not be null"); + this.variables = Objects.requireNonNull(variables, "variables must not be null"); + } + + /** + * Returns the list of multipart parts. + * + * @return the list of multipart parts + */ + public List parts() { + return parts; + } + + /** + * Returns the invocation-time parameter name to value map, used by the encoder to resolve {@code + * {name}} placeholders in part headers. + * + * @return the invocation-time parameter name to value map + */ + public Map variables() { + return variables; + } + + /** + * {@inheritDoc} + * + * @param object {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object object) { + if (!(object instanceof MultipartFormData)) return false; + MultipartFormData that = (MultipartFormData) object; + return Objects.equals(parts, that.parts) && Objects.equals(variables, that.variables); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(parts, variables); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public String toString() { + return "MultipartFormData{" + "parts=" + parts + ", variables=" + variables + '}'; + } +} diff --git a/api/src/main/java/feign/Part.java b/api/src/main/java/feign/Part.java new file mode 100644 index 0000000000..6950d4e3b4 --- /dev/null +++ b/api/src/main/java/feign/Part.java @@ -0,0 +1,70 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Declares a method parameter as one part of a {@code multipart/form-data} request body. + * + *

Part headers may contain Feign template expressions, such as {@code {name}}, which are + * resolved from other named parameters. + * + *

+ * @RequestLine("POST /")
+ * void upload(
+ *     @Part({
+ *       "Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"",
+ *       "Content-Type: text/plain"
+ *     })
+ *     Path file,
+ *     @Param("name") String name,
+ *     @Param("filename") String filename);
+ * 
+ * + * @apiNote Feign generates the multipart boundary; users should not include a boundary in part + * headers. + */ +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface Part { + /** + * Part header templates. This is a concise alias for {@link #headers()}. + * + * @apiNote if both {@code value} and {@code headers} are set, the contract should reject the + * method. + */ + String[] value() default {}; + + /** + * Part header templates. + * + * @apiNote each entry should be one header line, for example {@code Content-Type: text/plain}. + */ + String[] headers() default {}; + + /** + * Whether arrays and iterable values should be expanded into repeated parts. + * + * @apiNote {@code true} by default. When {@code false}, container values are encoded as a single + * part. + */ + boolean explode() default true; +} diff --git a/api/src/main/java/feign/PartData.java b/api/src/main/java/feign/PartData.java new file mode 100644 index 0000000000..556bcfe707 --- /dev/null +++ b/api/src/main/java/feign/PartData.java @@ -0,0 +1,136 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + +/** + * Runtime data for one multipart part, carrying the actual argument value and its header templates. + * + * @apiNote instances are produced by the multipart request-template factory and passed to the + * encoder for header resolution and wire-format serialization. The encoder is responsible for + * resolving any {@code {name}} placeholders in the header values using the {@link + * RequestTemplate}'s variable map. + */ +public class PartData { + private final Type type; + private final Object value; + private final Map> headers; + private final boolean unwrap; + + /** + * Creates runtime data for one multipart part. + * + * @param type the declared method parameter type of this part + * @param value the runtime argument value for this part + * @param headers part header templates, keyed by header name; values may contain {@code {name}} + * placeholders to be resolved by the encoder + * @param unwrap whether arrays and iterable values should be expanded into repeated parts + */ + public PartData( + Type type, Object value, Map> headers, boolean unwrap) { + this.type = Objects.requireNonNull(type, "type must not be null"); + this.value = value; + this.headers = Objects.requireNonNull(headers, "headers must not be null"); + this.unwrap = unwrap; + } + + /** + * Returns the declared method parameter type of this part. + * + * @return the declared method parameter type of this part + */ + public Type type() { + return type; + } + + /** + * Returns the runtime argument value for this part. + * + * @return the runtime argument value for this part, possibly {@code null} + */ + public Object value() { + return value; + } + + /** + * Returns the part header templates, keyed by header name. Values may contain {@code {name}} + * placeholders to be resolved by the encoder. + * + * @return the part header templates, keyed by header name + */ + public Map> headers() { + return headers; + } + + /** + * Returns whether arrays and iterable values should be expanded into repeated parts. + * + * @return {@code true} if arrays and iterable values should be expanded into repeated parts, + * {@code false} otherwise + */ + public boolean unwrap() { + return unwrap; + } + + /** + * {@inheritDoc} + * + * @param object {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object object) { + if (!(object instanceof PartData)) return false; + PartData partData = (PartData) object; + return unwrap == partData.unwrap + && Objects.equals(type, partData.type) + && Objects.equals(value, partData.value) + && Objects.equals(headers, partData.headers); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(type, value, headers, unwrap); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public String toString() { + return "PartData{" + + "type=" + + type + + ", value=" + + value + + ", headers=" + + headers + + ", unwrap=" + + unwrap + + '}'; + } +} diff --git a/api/src/main/java/feign/PartMetadata.java b/api/src/main/java/feign/PartMetadata.java new file mode 100644 index 0000000000..b014ce9ce2 --- /dev/null +++ b/api/src/main/java/feign/PartMetadata.java @@ -0,0 +1,129 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + +/** Declaration-time metadata for one {@link Part} method parameter. */ +public class PartMetadata { + private final int index; + private final Type type; + private final Map> headers; + private final boolean unwrap; + + /** + * Creates metadata for one multipart part parameter. + * + * @param index method parameter index + * @param type method parameter type + * @param headers part header templates declared by {@link Part}, keyed by header name; values may + * contain {@code {name}} placeholders to be resolved by the encoder + * @param unwrap whether arrays and iterable values should be expanded into repeated parts + */ + public PartMetadata( + int index, Type type, Map> headers, boolean unwrap) { + this.index = index; + this.type = Objects.requireNonNull(type, "type must not be null"); + this.headers = Objects.requireNonNull(headers, "headers must not be null"); + this.unwrap = unwrap; + } + + /** + * Returns the method parameter index of this part. + * + * @return the method parameter index of this part + */ + public int index() { + return index; + } + + /** + * Returns the method parameter type of this part. + * + * @return the method parameter type of this part + */ + public Type type() { + return type; + } + + /** + * Returns the part header templates declared by {@link Part}, keyed by header name. Values may + * contain {@code {name}} placeholders to be resolved by the encoder. + * + * @return the part header templates declared by {@link Part}, keyed by header name + */ + public Map> headers() { + return headers; + } + + /** + * Returns whether arrays and iterable values should be expanded into repeated parts. + * + * @return {@code true} if arrays and iterable values should be expanded into repeated parts, + * {@code false} otherwise + */ + public boolean unwrap() { + return unwrap; + } + + /** + * {@inheritDoc} + * + * @param object {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object object) { + if (!(object instanceof PartMetadata)) return false; + PartMetadata that = (PartMetadata) object; + return index == that.index + && unwrap == that.unwrap + && Objects.equals(type, that.type) + && Objects.equals(headers, that.headers); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(index, type, headers, unwrap); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public String toString() { + return "PartMetadata{" + + "index=" + + index + + ", type=" + + type + + ", headers=" + + headers + + ", unwrap=" + + unwrap + + '}'; + } +} diff --git a/api/src/main/java/feign/RequestTemplateFactoryResolver.java b/api/src/main/java/feign/RequestTemplateFactoryResolver.java index dc78b51e71..a41656306b 100644 --- a/api/src/main/java/feign/RequestTemplateFactoryResolver.java +++ b/api/src/main/java/feign/RequestTemplateFactoryResolver.java @@ -27,6 +27,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; final class RequestTemplateFactoryResolver { private final Encoder encoder; @@ -38,7 +39,9 @@ final class RequestTemplateFactoryResolver { } public RequestTemplate.Factory resolve(Target target, MethodMetadata md) { - if (!md.formParams().isEmpty() && md.template().bodyTemplate() == null) { + if (!md.partMetadata().isEmpty()) { + return new BuildMultipartTemplateFromArgs(md, encoder, queryMapEncoder, target); + } else if (!md.formParams().isEmpty() && md.template().bodyTemplate() == null) { return new BuildFormEncodedTemplateFromArgs(md, encoder, queryMapEncoder, target); } else if (md.bodyIndex() != null || md.alwaysEncodeBody()) { return new BuildEncodedTemplateFromArgs(md, encoder, queryMapEncoder, target); @@ -281,4 +284,45 @@ protected RequestTemplate resolve( return super.resolve(argv, mutable, variables); } } + + static class BuildMultipartTemplateFromArgs extends BuildTemplateByResolvingArgs { + private final Encoder encoder; + + BuildMultipartTemplateFromArgs( + MethodMetadata metadata, + Encoder encoder, + QueryMapEncoder queryMapEncoder, + Target target) { + super(metadata, queryMapEncoder, target); + this.encoder = encoder; + } + + @Override + protected RequestTemplate resolve( + Object[] argv, RequestTemplate mutable, Map variables) { + List parts = + metadata.partMetadata().entrySet().stream() + .map( + entry -> { + PartMetadata partMeta = entry.getValue(); + return new PartData( + partMeta.type(), + argv[entry.getKey()], + partMeta.headers(), + partMeta.unwrap()); + }) + .collect(Collectors.toList()); + MultipartFormData formData = new MultipartFormData(parts, variables); + + try { + encoder.encode(formData, MultipartFormData.class, mutable); + } catch (EncodeException e) { + throw e; + } catch (RuntimeException e) { + throw new EncodeException(e.getMessage(), e); + } + + return super.resolve(argv, mutable, variables); + } + } } diff --git a/api/src/test/java/feign/BuildMultipartTemplateFromArgsTest.java b/api/src/test/java/feign/BuildMultipartTemplateFromArgsTest.java new file mode 100644 index 0000000000..b3294a2717 --- /dev/null +++ b/api/src/test/java/feign/BuildMultipartTemplateFromArgsTest.java @@ -0,0 +1,121 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import feign.codec.EncodeException; +import feign.codec.Encoder; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BuildMultipartTemplateFromArgsTest { + private static final String PAYLOAD = "Hello, World!"; + private static final String PARAM_VALUE = "paramValue"; + private static final Map VARIABLES = Map.of("paramName", PARAM_VALUE); + + @Mock private Encoder encoder; + private RequestTemplate template; + private MultipartFormData formData; + private RequestTemplate.Factory factory; + + @BeforeEach + void setUp() { + var partIndex = 0; + var varIndex = 1; + var paramName = "paramName"; + var type = String.class; + var unwrap = true; + + var headers = + Map.>of( + "Content-Disposition", List.of("form-data; name=\"data\""), + "Content-Type", List.of("text/plain")); + + template = spy(new RequestTemplate()); + formData = + new MultipartFormData(List.of(new PartData(type, PAYLOAD, headers, unwrap)), VARIABLES); + + var methodMetadata = new MethodMetadata(); + + methodMetadata + .partMetadata() + .put(partIndex, new PartMetadata(partIndex, type, headers, unwrap)); + methodMetadata.indexToName().put(varIndex, List.of(paramName)); + + factory = new RequestTemplateFactoryResolver(encoder, mock()).resolve(mock(), methodMetadata); + } + + @Test + void shouldResolveMultipartTemplate() { + try (var requestTemplate = mockStatic(RequestTemplate.class)) { + requestTemplate.when(() -> RequestTemplate.from(any())).thenReturn(template); + + factory.create(new Object[] {PAYLOAD, PARAM_VALUE}); + } + + verify(encoder).encode(eq(formData), eq(MultipartFormData.class), notNull()); + verify(template).resolve(VARIABLES); + } + + @Test + void shouldRethrowEncodeException() { + var expected = mock(EncodeException.class); + + doThrow(expected).when(encoder).encode(eq(formData), eq(MultipartFormData.class), notNull()); + + try (var requestTemplate = mockStatic(RequestTemplate.class)) { + requestTemplate.when(() -> RequestTemplate.from(any())).thenReturn(template); + + assertThatThrownBy(() -> factory.create(new Object[] {PAYLOAD, PARAM_VALUE})) + .isEqualTo(expected); + } + + verify(encoder).encode(eq(formData), eq(MultipartFormData.class), notNull()); + } + + @Test + void shouldWrapRuntimeExceptionIntoEncodeException() { + var expected = new RuntimeException("Test error"); + + doThrow(expected).when(encoder).encode(eq(formData), eq(MultipartFormData.class), notNull()); + + try (var requestTemplate = mockStatic(RequestTemplate.class)) { + requestTemplate.when(() -> RequestTemplate.from(any())).thenReturn(template); + + assertThatThrownBy(() -> factory.create(new Object[] {PAYLOAD, PARAM_VALUE})) + .isInstanceOf(EncodeException.class) + .hasCause(expected); + } + + verify(encoder).encode(eq(formData), eq(MultipartFormData.class), notNull()); + } +} diff --git a/core/src/main/java/feign/core/DefaultContract.java b/core/src/main/java/feign/core/DefaultContract.java index 1feecd0c15..de0fe241cf 100644 --- a/core/src/main/java/feign/core/DefaultContract.java +++ b/core/src/main/java/feign/core/DefaultContract.java @@ -23,14 +23,19 @@ import feign.HeaderMap; import feign.Headers; import feign.Param; +import feign.Part; +import feign.PartMetadata; import feign.QueryMap; import feign.Request; import feign.Request.HttpMethod; import feign.RequestLine; +import feign.Types; import java.lang.reflect.Parameter; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -143,6 +148,41 @@ public DefaultContract() { "HeaderMap annotation was present on multiple parameters."); data.headerMapIndex(paramIndex); }); + super.registerParameterAnnotation( + Part.class, + (part, data, paramIndex) -> { + final String[] rawHeaders; + if (part.value().length > 0 && part.headers().length > 0) { + throw new IllegalStateException( + String.format( + "@Part on method %s parameter %d has both value() and headers() set; use one or the other", + data.configKey(), paramIndex)); + } else if (part.value().length > 0) { + rawHeaders = part.value(); + } else if (part.headers().length > 0) { + rawHeaders = part.headers(); + } else { + throw new IllegalStateException( + String.format( + "@Part on method %s parameter %d has neither value() nor headers() set; use one or the other", + data.configKey(), paramIndex)); + } + + final Map> headers = + rawHeaders.length == 1 && !rawHeaders[0].contains(":") + ? Map.of( + "Content-Disposition", + List.of("form-data; name=\"" + rawHeaders[0].trim() + '"')) + : toMap(rawHeaders); + final Type type = + Types.resolve( + data.targetType(), + data.targetType(), + data.method().getGenericParameterTypes()[paramIndex]); + final PartMetadata metadata = new PartMetadata(paramIndex, type, headers, part.explode()); + + data.partMetadata().put(paramIndex, metadata); + }); } private static Map> toMap(String[] input) { diff --git a/core/src/test/java/feign/core/DefaultContractMultipartTest.java b/core/src/test/java/feign/core/DefaultContractMultipartTest.java new file mode 100644 index 0000000000..ca934ca584 --- /dev/null +++ b/core/src/test/java/feign/core/DefaultContractMultipartTest.java @@ -0,0 +1,233 @@ +/* + * 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.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import feign.Contract; +import feign.MethodMetadata; +import feign.Param; +import feign.Part; +import feign.PartMetadata; +import feign.RequestLine; +import java.util.List; +import java.util.Map; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +class DefaultContractMultipartTest { + private static final Contract CONTRACT = new DefaultContract(); + + @Test + void shouldParseMultiHeaderPart() { + var expected = + new PartMetadata( + 0, + String.class, + Map.of( + "Content-Disposition", List.of("form-data; name=\"file\""), + "Content-Type", List.of("text/plain")), + true); + var metadata = CONTRACT.parseAndValidateMetadata(StandardPart.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, expected)); + } + + @Test + void shouldExpandShorthandValueToContentDisposition() { + var expected = + new PartMetadata( + 0, + String.class, + Map.of("Content-Disposition", List.of("form-data; name=\"file\"")), + true); + var metadata = CONTRACT.parseAndValidateMetadata(ShorthandPart.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, expected)); + } + + @Test + void shouldPreserveExplodeFlag() throws NoSuchMethodException { + var type = + PartWithExplodeDisabled.class.getMethod("upload", List.class).getGenericParameterTypes()[0]; + var expected = + new PartMetadata( + 0, type, Map.of("Content-Disposition", List.of("form-data; name=\"file\"")), false); + var metadata = CONTRACT.parseAndValidateMetadata(PartWithExplodeDisabled.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, expected)); + } + + @Test + void shouldParseHeadersAttribute() { + var expected = + new PartMetadata( + 0, + String.class, + Map.of( + "Content-Disposition", List.of("form-data; name=\"file\""), + "Content-Type", List.of("text/plain")), + true); + var metadata = CONTRACT.parseAndValidateMetadata(PartWithHeadersAttribute.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, expected)); + } + + @Test + void shouldRejectBothValueAndHeaders() { + assertThrows( + IllegalStateException.class, + () -> CONTRACT.parseAndValidateMetadata(PartWithBothValueAndHeaders.class)); + } + + @Test + void shouldRejectNeitherValueNorHeaders() { + assertThrows( + IllegalStateException.class, + () -> CONTRACT.parseAndValidateMetadata(PartWithNeitherValueNorHeaders.class)); + } + + @Test + void shouldResolveGenericType() throws NoSuchMethodException { + var type = + PartWithGenericType.class.getMethod("upload", List.class).getGenericParameterTypes()[0]; + var expected = + new PartMetadata( + 0, type, Map.of("Content-Disposition", List.of("form-data; name=\"file\"")), true); + var metadata = CONTRACT.parseAndValidateMetadata(PartWithGenericType.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, expected)); + } + + @Test + void shouldRejectBodyWithPart() { + assertThrows( + IllegalStateException.class, + () -> CONTRACT.parseAndValidateMetadata(PartWithBodyConflict.class)); + } + + @Test + void shouldAllowParamWithPart() { + var expected = + new PartMetadata( + 0, + String.class, + Map.of("Content-Disposition", List.of("form-data; name=\"file\"")), + true); + var metadata = CONTRACT.parseAndValidateMetadata(PartWithFormParamAllowed.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, expected)); + } + + @Test + void shouldAcceptMultipleParts() { + var part0 = + new PartMetadata( + 0, + String.class, + Map.of("Content-Disposition", List.of("form-data; name=\"file\"")), + true); + var part1 = + new PartMetadata( + 1, + byte[].class, + Map.of("Content-Disposition", List.of("form-data; name=\"data\"")), + true); + var metadata = CONTRACT.parseAndValidateMetadata(MultipleParts.class); + + assertThat(metadata) + .singleElement() + .extracting(MethodMetadata::partMetadata, InstanceOfAssertFactories.MAP) + .isEqualTo(Map.of(0, part0, 1, part1)); + } + + interface StandardPart { + @RequestLine("POST /upload") + void upload( + @Part({"Content-Disposition: form-data; name=\"file\"", "Content-Type: text/plain"}) + String file); + } + + interface ShorthandPart { + @RequestLine("POST /upload") + void upload(@Part("file") String file); + } + + interface PartWithExplodeDisabled { + @RequestLine("POST /upload") + void upload(@Part(value = "file", explode = false) List files); + } + + interface PartWithHeadersAttribute { + @RequestLine("POST /upload") + void upload( + @Part( + headers = { + "Content-Disposition: form-data; name=\"file\"", + "Content-Type: text/plain" + }) + String file); + } + + interface PartWithBothValueAndHeaders { + @RequestLine("POST /upload") + void upload(@Part(value = "file", headers = "Content-Type: text/plain") String file); + } + + interface PartWithNeitherValueNorHeaders { + @RequestLine("POST /upload") + void upload(@Part String file); + } + + interface PartWithGenericType { + @RequestLine("POST /upload") + void upload(@Part("file") List files); + } + + interface PartWithBodyConflict { + @RequestLine("POST /upload") + void upload(@Part("file") String file, String body); + } + + interface PartWithFormParamAllowed { + @RequestLine("POST /upload") + void upload(@Part("file") String file, @Param("form") String form); + } + + interface MultipleParts { + @RequestLine("POST /upload") + void upload(@Part("file") String file, @Part("data") byte[] data); + } +} From e3197fc8f278adaab5bfeb58b6f6fd4b9fda1450 Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Wed, 24 Jun 2026 23:18:10 +0300 Subject: [PATCH 2/5] feat: rename `PartData#explode` to `PartData#explode` Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> --- api/src/main/java/feign/PartData.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/api/src/main/java/feign/PartData.java b/api/src/main/java/feign/PartData.java index 556bcfe707..7865b67479 100644 --- a/api/src/main/java/feign/PartData.java +++ b/api/src/main/java/feign/PartData.java @@ -32,7 +32,7 @@ public class PartData { private final Type type; private final Object value; private final Map> headers; - private final boolean unwrap; + private final boolean explode; /** * Creates runtime data for one multipart part. @@ -41,14 +41,14 @@ public class PartData { * @param value the runtime argument value for this part * @param headers part header templates, keyed by header name; values may contain {@code {name}} * placeholders to be resolved by the encoder - * @param unwrap whether arrays and iterable values should be expanded into repeated parts + * @param explode whether arrays and iterable values should be expanded into repeated parts */ public PartData( - Type type, Object value, Map> headers, boolean unwrap) { + Type type, Object value, Map> headers, boolean explode) { this.type = Objects.requireNonNull(type, "type must not be null"); this.value = value; this.headers = Objects.requireNonNull(headers, "headers must not be null"); - this.unwrap = unwrap; + this.explode = explode; } /** @@ -86,7 +86,7 @@ public Map> headers() { * {@code false} otherwise */ public boolean unwrap() { - return unwrap; + return explode; } /** @@ -99,7 +99,7 @@ public boolean unwrap() { public boolean equals(Object object) { if (!(object instanceof PartData)) return false; PartData partData = (PartData) object; - return unwrap == partData.unwrap + return explode == partData.explode && Objects.equals(type, partData.type) && Objects.equals(value, partData.value) && Objects.equals(headers, partData.headers); @@ -112,7 +112,7 @@ public boolean equals(Object object) { */ @Override public int hashCode() { - return Objects.hash(type, value, headers, unwrap); + return Objects.hash(type, value, headers, explode); } /** @@ -129,8 +129,8 @@ public String toString() { + value + ", headers=" + headers - + ", unwrap=" - + unwrap + + ", explode=" + + explode + '}'; } } From 1101ee85e348177d3553b1fda6c9da08aa6b1e3f Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Fri, 26 Jun 2026 09:16:36 +0300 Subject: [PATCH 3/5] feat: rename `unwrap` to `explode` for clarity in multipart handling Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> --- api/src/main/java/feign/PartData.java | 2 +- api/src/main/java/feign/PartMetadata.java | 20 +++++++++---------- .../feign/RequestTemplateFactoryResolver.java | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/api/src/main/java/feign/PartData.java b/api/src/main/java/feign/PartData.java index 7865b67479..ef47cdd35d 100644 --- a/api/src/main/java/feign/PartData.java +++ b/api/src/main/java/feign/PartData.java @@ -85,7 +85,7 @@ public Map> headers() { * @return {@code true} if arrays and iterable values should be expanded into repeated parts, * {@code false} otherwise */ - public boolean unwrap() { + public boolean explode() { return explode; } diff --git a/api/src/main/java/feign/PartMetadata.java b/api/src/main/java/feign/PartMetadata.java index b014ce9ce2..246df05054 100644 --- a/api/src/main/java/feign/PartMetadata.java +++ b/api/src/main/java/feign/PartMetadata.java @@ -25,7 +25,7 @@ public class PartMetadata { private final int index; private final Type type; private final Map> headers; - private final boolean unwrap; + private final boolean explode; /** * Creates metadata for one multipart part parameter. @@ -34,14 +34,14 @@ public class PartMetadata { * @param type method parameter type * @param headers part header templates declared by {@link Part}, keyed by header name; values may * contain {@code {name}} placeholders to be resolved by the encoder - * @param unwrap whether arrays and iterable values should be expanded into repeated parts + * @param explode whether arrays and iterable values should be expanded into repeated parts */ public PartMetadata( - int index, Type type, Map> headers, boolean unwrap) { + int index, Type type, Map> headers, boolean explode) { this.index = index; this.type = Objects.requireNonNull(type, "type must not be null"); this.headers = Objects.requireNonNull(headers, "headers must not be null"); - this.unwrap = unwrap; + this.explode = explode; } /** @@ -78,8 +78,8 @@ public Map> headers() { * @return {@code true} if arrays and iterable values should be expanded into repeated parts, * {@code false} otherwise */ - public boolean unwrap() { - return unwrap; + public boolean explode() { + return explode; } /** @@ -93,7 +93,7 @@ public boolean equals(Object object) { if (!(object instanceof PartMetadata)) return false; PartMetadata that = (PartMetadata) object; return index == that.index - && unwrap == that.unwrap + && explode == that.explode && Objects.equals(type, that.type) && Objects.equals(headers, that.headers); } @@ -105,7 +105,7 @@ public boolean equals(Object object) { */ @Override public int hashCode() { - return Objects.hash(index, type, headers, unwrap); + return Objects.hash(index, type, headers, explode); } /** @@ -122,8 +122,8 @@ public String toString() { + type + ", headers=" + headers - + ", unwrap=" - + unwrap + + ", explode=" + + explode + '}'; } } diff --git a/api/src/main/java/feign/RequestTemplateFactoryResolver.java b/api/src/main/java/feign/RequestTemplateFactoryResolver.java index a41656306b..4dc338c137 100644 --- a/api/src/main/java/feign/RequestTemplateFactoryResolver.java +++ b/api/src/main/java/feign/RequestTemplateFactoryResolver.java @@ -309,7 +309,7 @@ protected RequestTemplate resolve( partMeta.type(), argv[entry.getKey()], partMeta.headers(), - partMeta.unwrap()); + partMeta.explode()); }) .collect(Collectors.toList()); MultipartFormData formData = new MultipartFormData(parts, variables); From 045c260a369d5e5293aa763b0dba719de94ee8c8 Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Wed, 1 Jul 2026 18:36:34 +0300 Subject: [PATCH 4/5] feat: create `MultipartFormEncoder` & `MultipartFileEncoder` Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> --- form-spring/pom.xml | 7 + .../feign/form/spring/MultipartFileBody.java | 60 ++ .../form/spring/MultipartFileEncoder.java | 107 +++ .../feign/form/spring/PartHeadersFactory.java | 62 ++ .../spring/MultipartFileEncoderTest.java | 188 +++++ form/pom.xml | 7 + .../java/feign/form/MultipartFormEncoder.java | 87 +++ .../form/multipart/ConditionalEncoder.java | 53 ++ .../form/multipart/ContentDisposition.java | 144 ++++ .../ContentTypeEncoderPredicate.java | 59 ++ .../form/multipart/EncoderPredicate.java | 46 ++ .../form/multipart/MultipartFormBody.java | 127 +++ .../java/feign/form/multipart/PartBody.java | 114 +++ .../feign/form/multipart/PartBodyFactory.java | 88 +++ .../form/multipart/PartDataFlattener.java | 70 ++ .../multipart/PartDataTemplateFactory.java | 119 +++ .../feign/form/multipart/Rfc5987Util.java | 85 ++ .../form/StreamingMultipartFormTest.java | 725 ++++++++++++++++++ .../multipart/ContentDispositionTest.java | 105 +++ .../feign/form/multipart/Rfc5987UtilTest.java | 40 + pom.xml | 1 + 21 files changed, 2294 insertions(+) create mode 100644 form-spring/src/main/java/feign/form/spring/MultipartFileBody.java create mode 100644 form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java create mode 100644 form-spring/src/main/java/feign/form/spring/PartHeadersFactory.java create mode 100644 form-spring/src/test/java/feign/form/feign/spring/MultipartFileEncoderTest.java create mode 100644 form/src/main/java/feign/form/MultipartFormEncoder.java create mode 100644 form/src/main/java/feign/form/multipart/ConditionalEncoder.java create mode 100644 form/src/main/java/feign/form/multipart/ContentDisposition.java create mode 100644 form/src/main/java/feign/form/multipart/ContentTypeEncoderPredicate.java create mode 100644 form/src/main/java/feign/form/multipart/EncoderPredicate.java create mode 100644 form/src/main/java/feign/form/multipart/MultipartFormBody.java create mode 100644 form/src/main/java/feign/form/multipart/PartBody.java create mode 100644 form/src/main/java/feign/form/multipart/PartBodyFactory.java create mode 100644 form/src/main/java/feign/form/multipart/PartDataFlattener.java create mode 100644 form/src/main/java/feign/form/multipart/PartDataTemplateFactory.java create mode 100644 form/src/main/java/feign/form/multipart/Rfc5987Util.java create mode 100644 form/src/test/java/feign/form/StreamingMultipartFormTest.java create mode 100644 form/src/test/java/feign/form/multipart/ContentDispositionTest.java create mode 100644 form/src/test/java/feign/form/multipart/Rfc5987UtilTest.java diff --git a/form-spring/pom.xml b/form-spring/pom.xml index ea75b7efd8..c805ac6c33 100644 --- a/form-spring/pom.xml +++ b/form-spring/pom.xml @@ -146,6 +146,13 @@ ${mockito.version} test
+ + + org.wiremock + wiremock-standalone + ${wiremock.version} + test + diff --git a/form-spring/src/main/java/feign/form/spring/MultipartFileBody.java b/form-spring/src/main/java/feign/form/spring/MultipartFileBody.java new file mode 100644 index 0000000000..ad40d1526b --- /dev/null +++ b/form-spring/src/main/java/feign/form/spring/MultipartFileBody.java @@ -0,0 +1,60 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.form.spring; + +import feign.Request; +import java.io.IOException; +import java.io.OutputStream; +import lombok.NonNull; +import org.springframework.web.multipart.MultipartFile; + +/** + * A {@link Request.Body} implementation that represents a {@link MultipartFile} body. + * + * @param multipartFile the {@link MultipartFile} to be sent as the request body + */ +record MultipartFileBody(@NonNull MultipartFile multipartFile) implements Request.Body { + /** + * Writes the {@link MultipartFile} content to the given output stream. + * + * @param outputStream {@inheritDoc} + * @throws IOException {@inheritDoc} + */ + @Override + public void writeTo(OutputStream outputStream) throws IOException { + multipartFile.getInputStream().transferTo(outputStream); + } + + /** + * Returns the content length of the {@link MultipartFile}. + * + * @return the content length of the {@link MultipartFile} + */ + @Override + public long contentLength() { + return multipartFile.getSize(); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public String toString() { + return "[Binary data (" + contentLength() + " bytes)]"; + } +} diff --git a/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java b/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java new file mode 100644 index 0000000000..170b82a4d9 --- /dev/null +++ b/form-spring/src/main/java/feign/form/spring/MultipartFileEncoder.java @@ -0,0 +1,107 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.form.spring; + +import feign.Request; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.form.MultipartFormEncoder; +import feign.form.multipart.MultipartFormBody; +import feign.form.multipart.PartBody; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.stream.Stream; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +/** + * An encoder that encodes {@link MultipartFile} objects into a multipart/form-data request body. + */ +@RequiredArgsConstructor +public class MultipartFileEncoder extends MultipartFormEncoder { + @NonNull private final Encoder delegate; + + /** + * Creates a new instance of {@link MultipartFileEncoder} with a default {@link + * MultipartFormEncoder} delegate. + */ + public MultipartFileEncoder() { + this(new MultipartFormEncoder()); + } + + private static Stream getMultipartFiles(Object object, Type bodyType) { + if (object instanceof MultipartFile multipartFile) { + return Stream.of(multipartFile); + } + + if (isMultipartFileCollection(bodyType)) { + return ((Collection) object).stream(); + } + + if (object instanceof MultipartFile[] multipartFiles) { + return Stream.of(multipartFiles); + } + + return null; + } + + private static boolean isMultipartFileCollection(Type bodyType) { + return bodyType instanceof ParameterizedType parameterizedType + && parameterizedType.getRawType() instanceof Class rawClass + && Collection.class.isAssignableFrom(rawClass) + && isMultipartFile(parameterizedType.getActualTypeArguments()); + } + + private static boolean isMultipartFile(Type[] actualTypeArguments) { + return actualTypeArguments.length > 0 + && actualTypeArguments[0] instanceof Class genericClass + && MultipartFile.class.isAssignableFrom(genericClass); + } + + private static Request.Body createPartBody(Object object) { + var multipartFile = (MultipartFile) object; + + return new PartBody( + PartHeadersFactory.create(multipartFile), new MultipartFileBody(multipartFile)); + } + + /** + * {@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 { + var multipartFiles = getMultipartFiles(object, bodyType); + + if (multipartFiles == null) { + delegate.encode(object, bodyType, template); + + return; + } + + var parts = multipartFiles.map(MultipartFileEncoder::createPartBody).toList(); + + super.encode(new MultipartFormBody(parts), MultipartFormBody.class, template); + } +} diff --git a/form-spring/src/main/java/feign/form/spring/PartHeadersFactory.java b/form-spring/src/main/java/feign/form/spring/PartHeadersFactory.java new file mode 100644 index 0000000000..4f099446a3 --- /dev/null +++ b/form-spring/src/main/java/feign/form/spring/PartHeadersFactory.java @@ -0,0 +1,62 @@ +/* + * 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 java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.experimental.UtilityClass; +import org.springframework.web.multipart.MultipartFile; + +/** A utility class that creates headers for a {@link MultipartFile} part. */ +@UtilityClass +class PartHeadersFactory { + private static final char DOUBLE_QUOTE = '"'; + + /** + * Creates headers for a {@link MultipartFile} part. + * + * @param multipartFile the {@link MultipartFile} to create headers for + * @return a map of headers for the {@link MultipartFile} part + */ + Map> create(MultipartFile multipartFile) { + var headers = new LinkedHashMap>(); + var contentDisposition = + new StringBuilder("form-data; name=") + .append(DOUBLE_QUOTE) + .append(multipartFile.getName()) + .append(DOUBLE_QUOTE); + var filename = multipartFile.getOriginalFilename(); + var contentType = multipartFile.getContentType(); + + if (filename != null) { + contentDisposition + .append("; filename=") + .append(DOUBLE_QUOTE) + .append(filename) + .append(DOUBLE_QUOTE); + } + + headers.put("Content-Disposition", List.of(contentDisposition.toString())); + + if (contentType != null) { + headers.put("Content-Type", List.of(contentType)); + } + + return headers; + } +} diff --git a/form-spring/src/test/java/feign/form/feign/spring/MultipartFileEncoderTest.java b/form-spring/src/test/java/feign/form/feign/spring/MultipartFileEncoderTest.java new file mode 100644 index 0000000000..47aa041e7f --- /dev/null +++ b/form-spring/src/test/java/feign/form/feign/spring/MultipartFileEncoderTest.java @@ -0,0 +1,188 @@ +/* + * 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.feign.spring; + +import static com.github.tomakehurst.wiremock.client.WireMock.aMultipart; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; + +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import feign.codec.Encoder; +import feign.form.spring.MultipartFileEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.multipart.MultipartFile; + +@SpringBootTest +@WireMockTest(httpPort = 8080) +public class MultipartFileEncoderTest { + private static final String REQUEST_PATH = "/"; + + @Autowired private StreamingMultipartFileTestClient testClient; + + @BeforeEach + void setUp() { + stubFor(post(REQUEST_PATH).willReturn(ok())); + } + + @Test + void shouldSendMultipartFile() { + var name = "data"; + var filename = "shouldSendMultipartFile.txt"; + var contentType = MediaType.TEXT_PLAIN_VALUE; + var expected = "Hello, World!"; + var multipartFile = + new MockMultipartFile( + name, filename, contentType, expected.getBytes(StandardCharsets.UTF_8)); + + testClient.sendMultipartFile(multipartFile); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(name) + .withFileName(filename) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(expected)) + .build())); + } + + @Test + void shouldSendMultipartFileArray() { + var name1 = "data1"; + var name2 = "data2"; + var filename1 = "shouldSendMultipartFileArray1.txt"; + var filename2 = "shouldSendMultipartFileArray2.txt"; + var contentType = MediaType.TEXT_PLAIN_VALUE; + var expected1 = "expected1"; + var expected2 = "expected2"; + var data = + new MultipartFile[] { + new MockMultipartFile( + name1, filename1, contentType, expected1.getBytes(StandardCharsets.UTF_8)), + new MockMultipartFile( + name2, filename2, contentType, expected2.getBytes(StandardCharsets.UTF_8)) + }; + + testClient.sendMultipartFileArray(data); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(name1) + .withFileName(filename1) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(expected1)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(name2) + .withFileName(filename2) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(expected2)) + .build())); + } + + @Test + void shouldSendMultipartFileCollection() { + var name1 = "data1"; + var name2 = "data2"; + var filename1 = "shouldSendMultipartFileArray1.txt"; + var filename2 = "shouldSendMultipartFileArray2.txt"; + var contentType = MediaType.TEXT_PLAIN_VALUE; + var expected1 = "expected1"; + var expected2 = "expected2"; + var data = + List.of( + new MockMultipartFile( + name1, filename1, contentType, expected1.getBytes(StandardCharsets.UTF_8)), + new MockMultipartFile( + name2, filename2, contentType, expected2.getBytes(StandardCharsets.UTF_8))); + + testClient.sendMultipartFileCollection(data); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(name1) + .withFileName(filename1) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(expected1)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(name2) + .withFileName(filename2) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(contentType)) + .withBody(equalTo(expected2)) + .build())); + } + + @FeignClient(name = "streamingMultipartFileTestClient", url = "http://localhost:8080") + private interface StreamingMultipartFileTestClient { + @RequestMapping( + value = REQUEST_PATH, + method = RequestMethod.POST, + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + void sendMultipartFile(@RequestBody MultipartFile data); + + @RequestMapping( + value = REQUEST_PATH, + method = RequestMethod.POST, + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + void sendMultipartFileArray(@RequestBody MultipartFile[] data); + + @RequestMapping( + value = REQUEST_PATH, + method = RequestMethod.POST, + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + void sendMultipartFileCollection(@RequestBody Collection data); + } + + @Configuration + private static class SpringStreamingMultipartFormTestConfiguration { + @Bean + public Encoder feignEncoder() { + return new MultipartFileEncoder(); + } + } +} diff --git a/form/pom.xml b/form/pom.xml index 4fa4da5ce7..5f2ac52f49 100644 --- a/form/pom.xml +++ b/form/pom.xml @@ -108,6 +108,13 @@ ${mockito.version} test + + + org.wiremock + wiremock + ${wiremock.version} + test + diff --git a/form/src/main/java/feign/form/MultipartFormEncoder.java b/form/src/main/java/feign/form/MultipartFormEncoder.java new file mode 100644 index 0000000000..70a014f891 --- /dev/null +++ b/form/src/main/java/feign/form/MultipartFormEncoder.java @@ -0,0 +1,87 @@ +/* + * 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; + +import feign.MultipartFormData; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.core.codec.DefaultEncoder; +import feign.form.multipart.MultipartFormBody; +import feign.form.multipart.PartBodyFactory; +import java.lang.reflect.Type; +import java.util.List; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +/** + * An encoder that encodes {@link MultipartFormData} and {@link MultipartFormBody} objects into a + * multipart/form-data request body. + */ +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MultipartFormEncoder implements Encoder { + @Builder.Default @NonNull private final Encoder delegate = defaultEncoder(); + + @Builder.Default @NonNull + private final PartBodyFactory partBodyFactory = new PartBodyFactory(List.of(defaultEncoder())); + + private static Encoder defaultEncoder() { + return new DefaultEncoder(); + } + + /** + * {@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 (object instanceof MultipartFormBody) { + var formBody = (MultipartFormBody) object; + + template.removeHeader("Content-Type"); + template.headerLiteral( + "Content-Type", "multipart/form-data; boundary=" + formBody.boundary()); + template.body(formBody); + + return; + } + + if (object instanceof MultipartFormData) { + var formData = (MultipartFormData) object; + var variables = formData.variables(); + var formBody = + formData.parts().stream() + .flatMap(part -> partBodyFactory.create(part, variables)) + .collect(Collectors.collectingAndThen(Collectors.toList(), MultipartFormBody::new)); + + encode(formBody, MultipartFormBody.class, template); + + return; + } + + delegate.encode(object, bodyType, template); + } +} 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 0000000000..2ed904097e --- /dev/null +++ b/form/src/main/java/feign/form/multipart/ConditionalEncoder.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 feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import java.lang.reflect.Type; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +/** + * An encoder that delegates to another encoder if the given object is supported by the provided + * predicate. Otherwise, an {@link EncodeException} is thrown. + */ +@RequiredArgsConstructor +public class ConditionalEncoder implements Encoder { + @NonNull private final Encoder delegate; + @NonNull private final EncoderPredicate encoderPredicate; + + /** + * Encodes the given object as the request body if it is supported by the provided predicate. + * + * @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( + String.format( + "Unsupported object received for encoding: %s, type: %s", object, bodyType)); + } + + delegate.encode(object, bodyType, template); + } +} diff --git a/form/src/main/java/feign/form/multipart/ContentDisposition.java b/form/src/main/java/feign/form/multipart/ContentDisposition.java new file mode 100644 index 0000000000..1960d1dee8 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/ContentDisposition.java @@ -0,0 +1,144 @@ +/* + * 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.UnsupportedEncodingException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Adapted from Apache CXF ({@code org.apache.cxf.attachment.ContentDisposition}) for standalone + * RFC-compliant header parsing. + */ +class ContentDisposition { + private static final String CD_HEADER_PARAMS_EXPRESSION = + "[\\w-]++( )?\\*?=( )?((\"[^\"]++\")|([^;]+))"; + private static final Pattern CD_HEADER_PARAMS_PATTERN = + Pattern.compile(CD_HEADER_PARAMS_EXPRESSION); + + private static final String CD_HEADER_EXT_PARAMS_EXPRESSION = + "(?i)(UTF-8|ISO-8859-1)''((?:%[0-9a-f]{2}|\\S)+)"; + private static final Pattern CD_HEADER_EXT_PARAMS_PATTERN = + Pattern.compile(CD_HEADER_EXT_PARAMS_EXPRESSION); + private static final Pattern CODEPOINT_ENCODED_VALUE_PATTERN = Pattern.compile("&#[0-9]{4};|\\S"); + + private static final String FILE_NAME = "filename"; + + private String value; + private String type; + private Map params = new LinkedHashMap<>(); + + ContentDisposition(String value) { + this.value = value; + + String tempValue = value; + + int index = tempValue.indexOf(';'); + if (index > 0 && tempValue.indexOf('=') >= index) { + type = tempValue.substring(0, index).trim(); + tempValue = tempValue.substring(index + 1); + } + + String extendedFilename = null; + Matcher m = CD_HEADER_PARAMS_PATTERN.matcher(tempValue); + while (m.find()) { + final String paramName; + String paramValue = ""; + + String groupValue = m.group().trim(); + int eqIndex = groupValue.indexOf('='); + if (eqIndex > 0) { + paramName = groupValue.substring(0, eqIndex).trim(); + if (eqIndex + 1 != groupValue.length()) { + paramValue = groupValue.substring(eqIndex + 1).trim().replace("\"", ""); + } + } else { + paramName = groupValue; + } + // filename* looks like the only CD param that is human readable + // and worthy of the extended encoding support. Other parameters + // can be supported if needed, see the complete list below + /* + http://www.iana.org/assignments/cont-disp/cont-disp.xhtml#cont-disp-2 + + filename name to be used when creating file [RFC2183] + creation-date date when content was created [RFC2183] + modification-date date when content was last modified [RFC2183] + read-date date when content was last read [RFC2183] + size approximate size of content in octets [RFC2183] + name original field name in form [RFC2388] + voice type or use of audio content [RFC2421] + handling whether or not processing is required [RFC3204] + */ + if ("filename*".equalsIgnoreCase(paramName)) { + // try to decode the value if it matches the spec + try { + Matcher matcher = CD_HEADER_EXT_PARAMS_PATTERN.matcher(paramValue); + if (matcher.matches()) { + String encodingScheme = matcher.group(1); + String encodedValue = matcher.group(2); + paramValue = Rfc5987Util.decode(encodedValue, encodingScheme); + extendedFilename = paramValue; + } + } catch (UnsupportedEncodingException e) { + // would be odd not to support UTF-8 or 8859-1 + } + } else if (FILE_NAME.equalsIgnoreCase(paramName) && paramValue.contains("&#")) { + Matcher matcher = CODEPOINT_ENCODED_VALUE_PATTERN.matcher(paramValue); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String matched = matcher.group(); + if (matched.startsWith("&#")) { + int codePoint = Integer.parseInt(matched.substring(2, 6)); + sb.append(Character.toChars(codePoint)); + } else { + sb.append(matched.charAt(0)); + } + } + if (sb.length() > 0) { + paramValue = sb.toString(); + } + } + params.put(paramName.toLowerCase(), paramValue); + } + if (extendedFilename != null) { + params.put(FILE_NAME, extendedFilename); + } + } + + String getType() { + return type; + } + + String getFilename() { + return params.get(FILE_NAME); + } + + String getParameter(String name) { + return params.get(name); + } + + Map getParameters() { + return Collections.unmodifiableMap(params); + } + + public String toString() { + return value; + } +} diff --git a/form/src/main/java/feign/form/multipart/ContentTypeEncoderPredicate.java b/form/src/main/java/feign/form/multipart/ContentTypeEncoderPredicate.java new file mode 100644 index 0000000000..2919a7daac --- /dev/null +++ b/form/src/main/java/feign/form/multipart/ContentTypeEncoderPredicate.java @@ -0,0 +1,59 @@ +/* + * 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 java.lang.reflect.Type; +import java.util.List; +import lombok.NonNull; + +/** + * An {@link EncoderPredicate} that checks if the request template has a {@code Content-Type} header + * that starts with the specified content type. + */ +class ContentTypeEncoderPredicate implements EncoderPredicate { + private final String contentType; + + /** + * Creates a new instance of {@link ContentTypeEncoderPredicate} with the specified content type. + * + * @param contentType the content type to check for in the request template's {@code Content-Type} + * header + */ + ContentTypeEncoderPredicate(@NonNull String contentType) { + this.contentType = normalizeHeader(contentType); + } + + private static String normalizeHeader(String header) { + return header.trim().toLowerCase(); + } + + /** + * Tests if the request template has a {@code Content-Type} header that starts with the specified + * content type. + * + * @param object {@inheritDoc} + * @param bodyType {@inheritDoc} + * @param template {@inheritDoc} + * @return {@code true} if the request template has a {@code Content-Type} header that starts with + * the specified content type, {@code false} otherwise + */ + @Override + public boolean test(Object object, Type bodyType, RequestTemplate template) { + return template.headers().getOrDefault("Content-Type", List.of()).stream() + .anyMatch(header -> normalizeHeader(header).startsWith(contentType)); + } +} 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 0000000000..be29986813 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/EncoderPredicate.java @@ -0,0 +1,46 @@ +/* + * 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 java.lang.reflect.Type; + +/** A predicate that determines whether a given object can be encoded by an encoder. */ +@FunctionalInterface +public interface EncoderPredicate { + /** + * Creates an {@link EncoderPredicate} that checks if the request template has a {@code + * Content-Type} header that starts with the specified content type. + * + * @param contentType the content type to check for in the request template's {@code Content-Type} + * header + * @return an {@link EncoderPredicate} that checks if the request template has a {@code + * Content-Type} header that starts with the specified content type + */ + static EncoderPredicate forContentType(String contentType) { + return new ContentTypeEncoderPredicate(contentType); + } + + /** + * Tests whether the given object can be encoded by an encoder. + * + * @param object the object to be encoded + * @param bodyType the type of the object to be encoded + * @param template the request template that will be used to encode the object + * @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/MultipartFormBody.java b/form/src/main/java/feign/form/multipart/MultipartFormBody.java new file mode 100644 index 0000000000..52dd5ede7e --- /dev/null +++ b/form/src/main/java/feign/form/multipart/MultipartFormBody.java @@ -0,0 +1,127 @@ +/* + * 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.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.UUID; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +/** A {@link Request.Body} implementation that represents a multipart form body. */ +@Data +@RequiredArgsConstructor +@Accessors(fluent = true) +public class MultipartFormBody implements Request.Body { + private static final String CRLF = "\r\n"; + private static final String DOUBLE_DASH = "--"; + + @NonNull private final Collection parts; + @NonNull private final String boundary; + + /** + * Creates a new {@link MultipartFormBody} with the given parts and a random boundary. + * + * @param parts the parts of the multipart form body + */ + public MultipartFormBody(Collection parts) { + this(parts, UUID.randomUUID().toString()); + } + + /** + * Writes the multipart form body to the given output stream. The body is written in the following + * format: + * + *
{@code
+   * --boundary
+   * headers
+   *
+   * body
+   * --boundary
+   * headers
+   *
+   * body
+   * ...
+   * --boundary--
+   * }
+ * + * @param outputStream {@inheritDoc} + * @throws IOException {@inheritDoc} + */ + @Override + public void writeTo(OutputStream outputStream) throws IOException { + for (var part : parts) { + writeString(outputStream, DOUBLE_DASH, boundary, CRLF); + part.writeTo(outputStream); + writeString(outputStream, CRLF); + } + + writeString(outputStream, DOUBLE_DASH, boundary, DOUBLE_DASH, CRLF); + } + + /** + * {@inheritDoc} + * + * @return the content length of the multipart form body, or {@code -1} if any of the parts has an + * unknown content length + */ + @Override + public long contentLength() { + var partsLengths = parts.stream().mapToLong(Request.Body::contentLength).summaryStatistics(); + + return partsLengths.getMin() < 0 + ? Request.Body.super.contentLength() + : partsLengths.getSum() + (6L + boundary.length()) * (parts.size() + 1); + } + + /** + * {@inheritDoc} + * + * @return {@code true} if all parts of the multipart form body are repeatable, {@code false} + * otherwise + */ + @Override + public boolean isRepeatable() { + return parts.stream().allMatch(Request.Body::isRepeatable); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public String toString() { + var builder = new StringBuilder(); + + parts.forEach( + part -> + builder.append(DOUBLE_DASH).append(boundary).append(CRLF).append(part).append(CRLF)); + + return builder.append(DOUBLE_DASH).append(boundary).append(DOUBLE_DASH).append(CRLF).toString(); + } + + private void writeString(OutputStream outputStream, String... values) throws IOException { + for (var value : values) { + outputStream.write(value.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/form/src/main/java/feign/form/multipart/PartBody.java b/form/src/main/java/feign/form/multipart/PartBody.java new file mode 100644 index 0000000000..0e739e63dd --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PartBody.java @@ -0,0 +1,114 @@ +/* + * 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 java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +/** A {@link Request.Body} implementation that represents a single part of a multipart form body. */ +@Data +@RequiredArgsConstructor +@Accessors(fluent = true) +public class PartBody implements Request.Body { + private static final String CRLF = "\r\n"; + + @NonNull private final Map> headers; + private final Request.Body body; + + /** + * Creates a new {@link PartBody} from the given {@link RequestTemplate}. + * + * @param template the {@link RequestTemplate} to create the {@link PartBody} from + * @return a new {@link PartBody} instance + */ + static PartBody from(@NonNull RequestTemplate template) { + return new PartBody(template.headers(), template.requestBody().orElse(null)); + } + + /** + * Writes the multipart part to the given output stream. The part is written in the following + * format: + * + *
{@code
+   * headers
+   *
+   * body content
+   * }
+ * + * @param outputStream {@inheritDoc} + * @throws IOException {@inheritDoc} + */ + @Override + public void writeTo(OutputStream outputStream) throws IOException { + outputStream.write(headersToString().getBytes(StandardCharsets.UTF_8)); + + if (body != null) { + body.writeTo(outputStream); + } + } + + /** + * Returns the content length of the multipart part, which is the sum of the content length of the + * body and the length of the headers. + * + * @return the content length of the multipart part, or {@code -1} if the content length of the + * body is unknown + */ + @Override + public long contentLength() { + var contentLength = body != null ? body.contentLength() : 0; + + return contentLength < 0 ? contentLength : contentLength + headersToString().length(); + } + + /** + * Returns the body of the multipart part, if present. + * + * @return an {@link Optional} containing the body of the multipart part, or an empty {@link + * Optional} if the body is not present + */ + public Optional body() { + return Optional.ofNullable(body); + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public String toString() { + return headersToString() + Objects.requireNonNullElse(body, ""); + } + + private String headersToString() { + return headers.entrySet().stream() + .flatMap(entry -> entry.getValue().stream().map(value -> entry.getKey() + ": " + value)) + .collect(Collectors.joining(CRLF, "", CRLF + CRLF)); + } +} 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 0000000000..ff7d520552 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PartBodyFactory.java @@ -0,0 +1,88 @@ +/* + * 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.PartData; +import feign.Request; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +/** + * A factory for creating {@link Request.Body} instances from {@link PartData} objects using a list + * of {@link Encoder} instances. + */ +@RequiredArgsConstructor +public class PartBodyFactory { + @NonNull private final List partBodyEncoders; + + /** + * Creates a stream of {@link Request.Body} instances from the given {@link PartData} and + * variables using the configured {@link Encoder} instances. + * + * @param partData the {@link PartData} to create the {@link Request.Body} instances from + * @param variables the variables to use for template expansion + * @return a stream of {@link Request.Body} instances + * @throws EncodeException if none of the configured {@link Encoder} instances can encode the + * given {@link PartData} + */ + public Stream create(PartData partData, Map variables) + throws EncodeException { + return PartDataFlattener.flatten(partData) + .map( + part -> + PartBody.from(tryEncode(part, PartDataTemplateFactory.create(part, variables)))); + } + + private RequestTemplate tryEncode(PartData partData, RequestTemplate template) + throws EncodeException { + if (partBodyEncoders.isEmpty()) { + throw new EncodeException("No part body encoders configured"); + } + + var value = partData.value(); + var bodyType = partData.type(); + var encodeExceptions = new ArrayDeque(); + + for (var encoder : partBodyEncoders) { + var mutable = RequestTemplate.from(template); + + try { + encoder.encode(value, bodyType, mutable); + + return mutable; + } catch (EncodeException e) { + encodeExceptions.add(e); + } + } + + var lastException = encodeExceptions.removeLast(); + + encodeExceptions.forEach(lastException::addSuppressed); + + throw new EncodeException( + String.format( + "None of the partBodyEncoders was able to encode object: %s, type: %s. Suppressed errors: %d", + value, bodyType, encodeExceptions.size()), + lastException); + } +} diff --git a/form/src/main/java/feign/form/multipart/PartDataFlattener.java b/form/src/main/java/feign/form/multipart/PartDataFlattener.java new file mode 100644 index 0000000000..7bb19d9668 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PartDataFlattener.java @@ -0,0 +1,70 @@ +/* + * 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.PartData; +import java.lang.reflect.Array; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import lombok.experimental.UtilityClass; + +/** + * A utility class that flattens a {@link PartData} object into a stream of {@link PartData} + * objects. + */ +@UtilityClass +class PartDataFlattener { + /** + * Flattens a {@link PartData} object into a stream of {@link PartData} objects. + * + * @param partData the {@link PartData} object to flatten + * @return a stream of {@link PartData} objects + */ + Stream flatten(PartData partData) { + var value = partData.value(); + + if (!partData.explode() || value == null) { + return Stream.of(partData); + } + + if (value instanceof Collection) { + return flatten(partData, getTypeArgument(partData.type()), ((Collection) value).stream()); + } + + if (value.getClass().isArray() && !(value instanceof byte[])) { + return flatten(partData, value.getClass().getComponentType(), arrayToStream(value)); + } + + return Stream.of(partData); + } + + private Stream flatten(PartData original, Type type, Stream values) { + return values.map(value -> new PartData(type, value, original.headers(), original.explode())); + } + + private Type getTypeArgument(Type type) { + return type instanceof ParameterizedType + ? ((ParameterizedType) type).getActualTypeArguments()[0] + : Object.class; + } + + private Stream arrayToStream(Object array) { + return IntStream.range(0, Array.getLength(array)).mapToObj(i -> Array.get(array, i)); + } +} diff --git a/form/src/main/java/feign/form/multipart/PartDataTemplateFactory.java b/form/src/main/java/feign/form/multipart/PartDataTemplateFactory.java new file mode 100644 index 0000000000..d688e2c251 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/PartDataTemplateFactory.java @@ -0,0 +1,119 @@ +/* + * 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.PartData; +import feign.RequestTemplate; +import java.io.File; +import java.net.URLConnection; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; + +/** A utility class that creates a {@link RequestTemplate} from a {@link PartData} object. */ +@UtilityClass +class PartDataTemplateFactory { + /** + * Creates a {@link RequestTemplate} from a {@link PartData} object. + * + * @param partData the {@link PartData} object to create the {@link RequestTemplate} from + * @param variables the variables to use for template expansion + * @return a {@link RequestTemplate} representing the given {@link PartData} object + */ + RequestTemplate create(PartData partData, Map variables) { + var template = new RequestTemplate(); + + copyHeaders(partData, template); + addContentTypeIfRequired(template); + + return template.resolve(variables); + } + + private void copyHeaders(PartData from, RequestTemplate to) { + var filename = getFilename(from.value()); + + from.headers().entrySet().stream() + .filter(entry -> entry.getKey() != null) + .forEach( + entry -> { + var name = entry.getKey().trim(); + var values = entry.getValue(); + var resolvedValues = + filename != null && "Content-Disposition".equalsIgnoreCase(name) + ? appendFilenameIfMissing(values, filename) + : values; + + to.header(name, resolvedValues); + }); + } + + private void addContentTypeIfRequired(RequestTemplate to) { + var headers = to.headers(); + + if (headers.containsKey("Content-Type")) { + return; + } + + headers.getOrDefault("Content-Disposition", List.of()).stream() + .map(contentDisposition -> new ContentDisposition(contentDisposition).getFilename()) + .filter(Objects::nonNull) + .findFirst() + .ifPresent(filename -> to.header("Content-Type", getContentType(filename))); + } + + private String getFilename(Object value) { + if (value instanceof File) { + return ((File) value).getName(); + } + + if (value instanceof Path) { + var fileName = ((Path) value).getFileName(); + + if (fileName != null) { + return fileName.toString(); + } + } + + return null; + } + + private Collection appendFilenameIfMissing(Collection values, String filename) { + return values.stream() + .map(value -> appendFilenameIfMissing(value, filename)) + .collect(Collectors.toList()); + } + + private String appendFilenameIfMissing(String value, String filename) { + return value != null && isMissingFilename(new ContentDisposition(value)) + ? value + "; filename=\"" + filename + '"' + : value; + } + + private boolean isMissingFilename(ContentDisposition contentDisposition) { + return "form-data".equals(contentDisposition.getType()) + && contentDisposition.getFilename() == null; + } + + private String getContentType(String filename) { + var contentType = URLConnection.guessContentTypeFromName(filename); + + return contentType != null ? contentType : "application/octet-stream"; + } +} diff --git a/form/src/main/java/feign/form/multipart/Rfc5987Util.java b/form/src/main/java/feign/form/multipart/Rfc5987Util.java new file mode 100644 index 0000000000..e992df02e8 --- /dev/null +++ b/form/src/main/java/feign/form/multipart/Rfc5987Util.java @@ -0,0 +1,85 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility for encoding and decoding values according to RFC 5987. Assumes the caller already knows + * the encoding scheme for the value. + * + *

Adapted from Apache CXF ({@code org.apache.cxf.attachment.ContentDisposition}). + */ +final class Rfc5987Util { + + private static final Pattern ENCODED_VALUE_PATTERN = + Pattern.compile("%[0-9a-f]{2}|\\S", Pattern.CASE_INSENSITIVE); + + private Rfc5987Util() {} + + static String encode(final String s) throws UnsupportedEncodingException { + return encode(s, StandardCharsets.UTF_8.name()); + } + + // http://stackoverflow.com/questions/11302361/ (continued next line) + // handling-filename-parameters-with-spaces-via-rfc-5987-results-in-in-filenam + static String encode(final String s, String encoding) throws UnsupportedEncodingException { + final byte[] rawBytes = s.getBytes(encoding); + final int len = rawBytes.length; + final StringBuilder sb = new StringBuilder(len << 1); + final char[] digits = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + final byte[] attributeChars = { + '!', '#', '$', '&', '+', '-', '.', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', + 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', + 'V', 'W', 'X', 'Y', 'Z', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', + 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '|', '~' + }; + for (final byte b : rawBytes) { + if (Arrays.binarySearch(attributeChars, b) >= 0) { + sb.append((char) b); + } else { + sb.append('%'); + sb.append(digits[0x0f & (b >>> 4)]); + sb.append(digits[b & 0x0f]); + } + } + + return sb.toString(); + } + + static String decode(String s, String encoding) throws UnsupportedEncodingException { + Matcher matcher = ENCODED_VALUE_PATTERN.matcher(s); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + while (matcher.find()) { + String matched = matcher.group(); + if (matched.startsWith("%")) { + int value = Integer.parseInt(matched.substring(1), 16); + bos.write(value); + } else { + bos.write(matched.charAt(0)); + } + } + + return new String(bos.toByteArray(), encoding); + } +} diff --git a/form/src/test/java/feign/form/StreamingMultipartFormTest.java b/form/src/test/java/feign/form/StreamingMultipartFormTest.java new file mode 100644 index 0000000000..b6d52ca38c --- /dev/null +++ b/form/src/test/java/feign/form/StreamingMultipartFormTest.java @@ -0,0 +1,725 @@ +/* + * 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; + +import static com.github.tomakehurst.wiremock.client.WireMock.aMultipart; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import feign.Feign; +import feign.Param; +import feign.Part; +import feign.Request; +import feign.RequestLine; +import feign.codec.EncodeException; +import feign.core.codec.DefaultEncoder; +import feign.form.multipart.ConditionalEncoder; +import feign.form.multipart.EncoderPredicate; +import feign.form.multipart.PartBodyFactory; +import feign.jackson.JacksonEncoder; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +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; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +@WireMockTest +public class StreamingMultipartFormTest { + private static final String CLASSNAME = StreamingMultipartFormTest.class.getSimpleName(); + private static final String REQUEST_PATH = "/"; + private static final String REQUEST_LINE = "POST " + REQUEST_PATH; + private static final String PARAM_NAME = "data"; + + private MultipartFormTestClient testClient; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + testClient = + Feign.builder() + .encoder( + MultipartFormEncoder.builder() + .partBodyFactory( + new PartBodyFactory( + List.of( + new ConditionalEncoder( + new JacksonEncoder(), + EncoderPredicate.forContentType( + MediaType.APPLICATION_JSON_VALUE)), + new DefaultEncoder()))) + .build()) + .target(MultipartFormTestClient.class, wmRuntimeInfo.getHttpBaseUrl()); + + stubFor(post(REQUEST_PATH).willReturn(ok())); + } + + @Test + void shouldSendShorthandString() { + var body = "Hello, World!"; + + testClient.sendShorthandString(body); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(body)).build())); + } + + @Test + void shouldSendShorthandByteArray() { + var body = "Hello, World!"; + + testClient.sendShorthandByteArray(body.getBytes(StandardCharsets.UTF_8)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(body)).build())); + } + + @Test + void shouldSendShorthandFile(@TempDir File tempDir) throws IOException { + var body = "Hello, World!"; + var file = new File(tempDir, CLASSNAME + "_shouldSendShorthandFile.txt"); + + Files.writeString(file.toPath(), body); + + testClient.sendShorthandFile(file); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendShorthandPath(@TempDir File tempDir) throws IOException { + var body = "Hello, World!"; + var file = new File(tempDir, CLASSNAME + "_shouldSendShorthandPath.txt"); + + Files.writeString(file.toPath(), body); + + testClient.sendShorthandPath(file.toPath()); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendShorthandInputStream() throws IOException { + var body = "Hello, World!"; + @Cleanup var inputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); + + testClient.sendShorthandInputStream(inputStream); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(body)).build())); + } + + @Test + void shouldSendShorthandRequestBody() { + var body = "Hello, World!"; + + testClient.sendShorthandRequestBody(Request.Body.of(body)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(body)).build())); + } + + @Test + void shouldSendShorthandStringCollection() { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var catwoman = "Catwoman"; + var villains = List.of(joker, harleyQuinn, catwoman); + + testClient.sendShorthandStringCollection(villains); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart(aMultipart().withName(PARAM_NAME).withBody(equalTo(joker)).build()) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(harleyQuinn)).build()) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(catwoman)).build())); + } + + @Test + void shouldSendShorthandFileArray(@TempDir File tempDir) throws IOException { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var file1 = new File(tempDir, CLASSNAME + "_shouldSendShorthandFileArray_1.txt"); + var file2 = new File(tempDir, CLASSNAME + "_shouldSendShorthandFileArray_2.txt"); + + Files.writeString(file1.toPath(), joker); + Files.writeString(file2.toPath(), harleyQuinn); + + testClient.sendShorthandFileArray(new File[] {file1, file2}); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file1.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(joker)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file2.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(harleyQuinn)) + .build())); + } + + @Test + void shouldSendShorthandFileCollection(@TempDir File tempDir) throws IOException { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var file1 = new File(tempDir, CLASSNAME + "_shouldSendShorthandFileCollection_1.txt"); + var file2 = new File(tempDir, CLASSNAME + "_shouldSendShorthandFileCollection_2.txt"); + + Files.writeString(file1.toPath(), joker); + Files.writeString(file2.toPath(), harleyQuinn); + + testClient.sendShorthandFileCollection(List.of(file1, file2)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file1.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(joker)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file2.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(harleyQuinn)) + .build())); + } + + @Test + void shouldSendShorthandPathArray(@TempDir Path tempDir) throws IOException { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var path1 = tempDir.resolve(CLASSNAME + "_shouldSendShorthandPathArray_1.txt"); + var path2 = tempDir.resolve(CLASSNAME + "_shouldSendShorthandPathArray_2.txt"); + + Files.writeString(path1, joker); + Files.writeString(path2, harleyQuinn); + + testClient.sendShorthandPathArray(new Path[] {path1, path2}); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(path1.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(joker)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(path2.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(harleyQuinn)) + .build())); + } + + @Test + void shouldSendShorthandPathCollection(@TempDir Path tempDir) throws IOException { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var path1 = tempDir.resolve(CLASSNAME + "_shouldSendShorthandPathCollection_1.txt"); + var path2 = tempDir.resolve(CLASSNAME + "_shouldSendShorthandPathCollection_2.txt"); + + Files.writeString(path1, joker); + Files.writeString(path2, harleyQuinn); + + testClient.sendShorthandPathCollection(List.of(path1, path2)); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(path1.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(joker)) + .build()) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(path2.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(harleyQuinn)) + .build())); + } + + @Test + void shouldSendStringWithFullContentDispositionHeader() { + var body = "Hello, World!"; + + testClient.sendStringWithFullContentDispositionHeader(body); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(body)).build())); + } + + @Test + void shouldSendShorthandStringArray() { + var joker = "Joker"; + var harleyQuinn = "Harley Quinn"; + var catwoman = "Catwoman"; + var villains = new String[] {joker, harleyQuinn, catwoman}; + + testClient.sendShorthandStringArray(villains); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart(aMultipart().withName(PARAM_NAME).withBody(equalTo(joker)).build()) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(harleyQuinn)).build()) + .withRequestBodyPart( + aMultipart().withName(PARAM_NAME).withBody(equalTo(catwoman)).build())); + } + + @Test + void shouldSendStringWithFullContentDispositionAndContentTypeHeaders() { + var body = "## Hello, World!"; + + testClient.sendStringWithFullContentDispositionAndContentTypeHeaders(body); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_MARKDOWN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendStringWithFullContentDispositionFilenameAndContentTypeHeaders() { + var body = "## Hello, World!"; + + testClient.sendStringWithFullContentDispositionFilenameAndContentTypeHeaders(body); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName("file.md") + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_MARKDOWN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendFileWithFullContentDispositionHeader(@TempDir File tempDir) throws IOException { + var body = "Hello, World!"; + var file = new File(tempDir, CLASSNAME + "_shouldSendFileWithFullContentDispositionHeader.txt"); + + Files.writeString(file.toPath(), body); + + testClient.sendFileWithFullContentDispositionHeader(file); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendPathWithFullContentDispositionHeader(@TempDir Path tempDir) throws IOException { + var body = "Hello, World!"; + var path = tempDir.resolve(CLASSNAME + "_shouldSendPathWithFullContentDispositionHeader.txt"); + + Files.writeString(path, body); + + testClient.sendPathWithFullContentDispositionHeader(path); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(path.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_PLAIN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendFileWithFullContentDispositionFilename(@TempDir File tempDir) throws IOException { + var body = "## Hello, World!"; + var file = + new File(tempDir, CLASSNAME + "_shouldSendFileWithFullContentDispositionFilename.txt"); + + Files.writeString(file.toPath(), body); + + testClient.sendFileWithFullContentDispositionAndFilename(file); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName("file.md") + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_MARKDOWN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendPathWithFullContentDispositionFilename(@TempDir Path tempDir) throws IOException { + var body = "## Hello, World!"; + var path = tempDir.resolve(CLASSNAME + "_shouldSendPathWithFullContentDispositionFilename.txt"); + + Files.writeString(path, body); + + testClient.sendPathWithFullContentDispositionAndFilename(path); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName("file.md") + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_MARKDOWN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendFileWithFullContentDispositionAndContentTypeHeaders(@TempDir File tempDir) + throws IOException { + var body = "## Hello, World!"; + var file = + new File( + tempDir, + CLASSNAME + "_shouldSendFileWithFullContentDispositionAndContentTypeHeaders.txt"); + + Files.writeString(file.toPath(), body); + + testClient.sendFileWithFullContentDispositionAndContentTypeHeaders(file); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_MARKDOWN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendPathWithFullContentDispositionAndContentTypeHeaders(@TempDir Path tempDir) + throws IOException { + var body = "## Hello, World!"; + var file = + tempDir.resolve( + CLASSNAME + "_shouldSendPathWithFullContentDispositionAndContentTypeHeaders.txt"); + + Files.writeString(file, body); + + testClient.sendPathWithFullContentDispositionAndContentTypeHeaders(file); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.TEXT_MARKDOWN_VALUE)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendShorthandFileUnknownContentType(@TempDir File tempDir) throws IOException { + var body = "Hello, World!"; + var file = new File(tempDir, CLASSNAME + "_shouldSendShorthandFileUnknownContentType.unknown"); + + Files.writeString(file.toPath(), body); + + testClient.sendShorthandFile(file); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(file.getName()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing("application/octet-stream")) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendShorthandPathUnknownContentType(@TempDir Path tempDir) throws IOException { + var body = "Hello, World!"; + var path = tempDir.resolve(CLASSNAME + "_shouldSendShorthandPathUnknownContentType.unknown"); + + Files.writeString(path, body); + + testClient.sendShorthandPath(path); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withFileName(path.getFileName().toString()) + .withHeader(HttpHeaders.CONTENT_TYPE, containing("application/octet-stream")) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendWithParameterizedHeaders() { + var body = "## Hello, World!"; + var name = "data"; + var filename = "file.md"; + var contentType = MediaType.TEXT_MARKDOWN_VALUE; + + testClient.sendWithParameterizedHeaders(body, name, filename, contentType); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(name) + .withFileName(filename) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(contentType)) + .withBody(equalTo(body)) + .build())); + } + + @Test + void shouldSendJsonMovie() { + var movie = new Movie("Inception", "Christopher Nolan"); + var expected = + """ + { + "title": "Inception", + "director": "Christopher Nolan" + } + """; + + testClient.sendJsonMovie(movie); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_PATH)) + .withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE)) + .withRequestBodyPart( + aMultipart() + .withName(PARAM_NAME) + .withHeader( + HttpHeaders.CONTENT_TYPE, containing(MediaType.APPLICATION_JSON_VALUE)) + .withBody(equalToJson(expected)) + .build())); + } + + @Test + void shouldFailToSendXmlMovie() { + var movie = new Movie("Inception", "Christopher Nolan"); + + assertThrows(EncodeException.class, () -> testClient.sendXmlMovie(movie)); + } + + private interface MultipartFormTestClient { + @RequestLine(REQUEST_LINE) + void sendShorthandString(@Part(PARAM_NAME) String data); + + @RequestLine(REQUEST_LINE) + void sendShorthandByteArray(@Part(PARAM_NAME) byte[] data); + + @RequestLine(REQUEST_LINE) + void sendShorthandFile(@Part(PARAM_NAME) File data); + + @RequestLine(REQUEST_LINE) + void sendShorthandPath(@Part(PARAM_NAME) Path data); + + @RequestLine(REQUEST_LINE) + void sendShorthandInputStream(@Part(PARAM_NAME) InputStream data); + + @RequestLine(REQUEST_LINE) + void sendShorthandRequestBody(@Part(PARAM_NAME) Request.Body data); + + @RequestLine(REQUEST_LINE) + void sendShorthandStringCollection(@Part(PARAM_NAME) Collection data); + + @RequestLine(REQUEST_LINE) + void sendShorthandStringArray(@Part(PARAM_NAME) String[] data); + + @RequestLine(REQUEST_LINE) + void sendShorthandFileArray(@Part(PARAM_NAME) File[] data); + + @RequestLine(REQUEST_LINE) + void sendShorthandFileCollection(@Part(PARAM_NAME) Collection data); + + @RequestLine(REQUEST_LINE) + void sendShorthandPathArray(@Part(PARAM_NAME) Path[] data); + + @RequestLine(REQUEST_LINE) + void sendShorthandPathCollection(@Part(PARAM_NAME) Collection data); + + @RequestLine(REQUEST_LINE) + void sendStringWithFullContentDispositionHeader( + @Part("Content-Disposition: form-data; name=\"data\"") String data); + + @RequestLine(REQUEST_LINE) + void sendStringWithFullContentDispositionAndContentTypeHeaders( + @Part({"Content-Disposition: form-data; name=\"data\"", "Content-Type: text/markdown"}) + String data); + + @RequestLine(REQUEST_LINE) + void sendStringWithFullContentDispositionFilenameAndContentTypeHeaders( + @Part({ + "Content-Disposition: form-data; name=\"data\"; filename=\"file.md\"", + "Content-Type: text/markdown" + }) + String data); + + @RequestLine(REQUEST_LINE) + void sendFileWithFullContentDispositionHeader( + @Part("Content-Disposition: form-data; name=\"data\"") File data); + + @RequestLine(REQUEST_LINE) + void sendPathWithFullContentDispositionHeader( + @Part("Content-Disposition: form-data; name=\"data\"") Path data); + + @RequestLine(REQUEST_LINE) + void sendFileWithFullContentDispositionAndFilename( + @Part("Content-Disposition: form-data; name=\"data\"; filename=\"file.md\"") File data); + + @RequestLine(REQUEST_LINE) + void sendPathWithFullContentDispositionAndFilename( + @Part("Content-Disposition: form-data; name=\"data\"; filename=\"file.md\"") Path data); + + @RequestLine(REQUEST_LINE) + void sendFileWithFullContentDispositionAndContentTypeHeaders( + @Part({"Content-Disposition: form-data; name=\"data\"", "Content-Type: text/markdown"}) + File data); + + @RequestLine(REQUEST_LINE) + void sendPathWithFullContentDispositionAndContentTypeHeaders( + @Part({"Content-Disposition: form-data; name=\"data\"", "Content-Type: text/markdown"}) + Path data); + + @RequestLine(REQUEST_LINE) + void sendWithParameterizedHeaders( + @Part({ + "Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"", + "Content-Type: {contentType}" + }) + String data, + @Param("name") String name, + @Param("filename") String filename, + @Param("contentType") String contentType); + + @RequestLine(REQUEST_LINE) + void sendJsonMovie( + @Part({"Content-Disposition: form-data; name=\"data\"", "Content-Type: application/json"}) + Movie movie); + + @RequestLine(REQUEST_LINE) + void sendXmlMovie( + @Part({"Content-Disposition: form-data; name=\"data\"", "Content-Type: application/xml"}) + Movie movie); + } + + private record Movie(String title, String director) {} +} diff --git a/form/src/test/java/feign/form/multipart/ContentDispositionTest.java b/form/src/test/java/feign/form/multipart/ContentDispositionTest.java new file mode 100644 index 0000000000..efd0a19631 --- /dev/null +++ b/form/src/test/java/feign/form/multipart/ContentDispositionTest.java @@ -0,0 +1,105 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class ContentDispositionTest { + @Test + void testParseSimpleFilename() { + var cd = new ContentDisposition("attachment; filename=foo.txt"); + + assertEquals("attachment", cd.getType()); + assertEquals("foo.txt", cd.getFilename()); + } + + @Test + void testParseQuotedFilename() { + var cd = new ContentDisposition("form-data; name=\"field\"; filename=\"bar.txt\""); + + assertEquals("form-data", cd.getType()); + assertEquals("field", cd.getParameter("name")); + assertEquals("bar.txt", cd.getFilename()); + } + + @Test + void testParseFilenameWithSpaces() { + var cd = new ContentDisposition("attachment; filename=\"my report.pdf\""); + + assertEquals("my report.pdf", cd.getFilename()); + } + + @Test + void testParseFilenameWithSemicolon() { + var cd = new ContentDisposition("attachment; filename=\"data;v2.csv\""); + + assertEquals("data;v2.csv", cd.getFilename()); + } + + @Test + void testParseFilenameStarPrecedence() { + var cd = + new ContentDisposition( + "attachment; filename=\"fallback.txt\"; filename*=UTF-8''%E2%82%AC%20rates.txt"); + + assertEquals("€ rates.txt", cd.getFilename()); + } + + @Test + void testParseEmptyOrMissingFilename() { + var cdEmpty = new ContentDisposition(""); + + assertNull(cdEmpty.getType()); + assertNull(cdEmpty.getFilename()); + + var cdNoFile = new ContentDisposition("form-data; name=\"text-field\""); + + assertEquals("form-data", cdNoFile.getType()); + assertNull(cdNoFile.getFilename()); + } + + @Test + void testParseCaseInsensitivity() { + var cd = new ContentDisposition("ATTACHMENT; FILENAME=\"lowercase.txt\""); + + assertEquals("lowercase.txt", cd.getFilename()); + } + + @Test + void testGetParameters() { + var cd = + new ContentDisposition( + "form-data; NAME=\"avatar\"; filename=\"profile.png\"; Size=12345; custom-param=xyz"); + + var parameters = cd.getParameters(); + + assertNotNull(parameters); + assertEquals(4, parameters.size()); + + assertEquals("avatar", parameters.get("name")); + assertEquals("profile.png", parameters.get("filename")); + assertEquals("12345", parameters.get("size")); + assertEquals("xyz", parameters.get("custom-param")); + + assertThrows( + UnsupportedOperationException.class, () -> parameters.put("new-key", "illegal-append")); + } +} diff --git a/form/src/test/java/feign/form/multipart/Rfc5987UtilTest.java b/form/src/test/java/feign/form/multipart/Rfc5987UtilTest.java new file mode 100644 index 0000000000..60a2c6ab78 --- /dev/null +++ b/form/src/test/java/feign/form/multipart/Rfc5987UtilTest.java @@ -0,0 +1,40 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** Adapted from Apache CXF ({@code org.apache.cxf.attachment.Rfc5987UtilTest}). */ +public class Rfc5987UtilTest { + @ParameterizedTest + @CsvSource( + textBlock = + """ + foo-ä-€.html, foo-%c3%a4-%e2%82%ac.html + 世界ーファイル 2.jpg, %e4%b8%96%e7%95%8c%e3%83%bc%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%202.jpg, + foo.jpg, foo.jpg + """) + void test(String input, String expected) throws UnsupportedEncodingException { + assertEquals(expected, Rfc5987Util.encode(input, StandardCharsets.UTF_8.name())); + + assertEquals(input, Rfc5987Util.decode(expected, StandardCharsets.UTF_8.name())); + } +} diff --git a/pom.xml b/pom.xml index f85a2977da..60e17a9cab 100644 --- a/pom.xml +++ b/pom.xml @@ -181,6 +181,7 @@ 5.23.0 2.0.61.android8 1.5.3 + 3.13.2 6.0 3.15.0 From 96786a6f49cc76121c776c2b6bce83dd658289f4 Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Wed, 1 Jul 2026 19:57:14 +0300 Subject: [PATCH 5/5] docs: describe new features in the `README.md`, `MIGRATION-v14.md` & `CHANGELOG.md` Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> --- CHANGELOG.md | 3 + MIGRATION-v14.md | 9 +++ README.md | 168 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b745f14a..d98f27247a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ * `DefaultEncoder` now supports streaming request bodies for `File`, `Path`, `InputStream`, and `Request.Body` types, avoiding in-memory buffering. New `Request.PathBody` and `Request.InputStreamBody` implementations are provided for these cases. (https://github.com/OpenFeign/feign/pull/3396) +* New `@Part` annotation and `MultipartFormEncoder` for declarative, streaming `multipart/form-data` requests. + `MultipartFileEncoder` (in `feign-form-spring`) extends this with direct Spring `MultipartFile` support. + (https://github.com/OpenFeign/feign/pull/3450) ### Version 13.12 diff --git a/MIGRATION-v14.md b/MIGRATION-v14.md index e91bfbe27c..eb65bb718b 100644 --- a/MIGRATION-v14.md +++ b/MIGRATION-v14.md @@ -25,6 +25,15 @@ The **breaking changes** primarily affect code that interacts directly with requ This is an additive, non-breaking change. Existing `DefaultEncoder` users do not need to modify code; users can now opt into streaming by passing these types. +### `@Part` annotation & multipart encoder (non-breaking) (https://github.com/OpenFeign/feign/pull/3450) + +A new `@Part` parameter annotation provides first-class, declarative support for `multipart/form-data` requests. +The `MultipartFormEncoder` (in `feign-form`) serializes `@Part`-annotated parameters into standards-compliant multipart +bodies with streaming support. For Spring users, `MultipartFileEncoder` (in `feign-form-spring`) extends this to +directly accept Spring's `MultipartFile` objects. + +These are additive, non-breaking additions. Existing form-encoded and single-body contracts are unaffected. + --- ## Breaking Changes diff --git a/README.md b/README.md index cbaae10766..fb4e6f7c4d 100644 --- a/README.md +++ b/README.md @@ -1444,6 +1444,149 @@ In the example above, the `sendPhoto` method uses the `photo` parameter using th someApi.sendPhoto(true, formData); ``` +#### Streaming multipart with `@Part` and `MultipartFormEncoder` (since v14) + +As of Feign v14, parts are streamed directly to the output without buffering the entire body in memory. +Declare multipart contracts with the `@Part` annotation and wire up `MultipartFormEncoder`: + +```java +interface UploadApi { + @RequestLine("POST /upload") + void upload( + @Part("file") Path file, + + // Collection/array parts are exploded by default (explode = true); + // each element becomes a separate part. + // + // Use @Part(value = "tags", explode = false) to send tags as a single part + // (assuming there's an Encoder that supports List types). + @Part("tags") List tags, + + // Header templates accept @Param-expanded variables + @Part({ + "Content-Disposition: form-data; name=\"custom\"", + "Content-Type: {contentType}" + }) + String customPart, + + @Param("contentType") String contentType + ); +} + +public class Example { + public static void main(String[] args) { + UploadApi client = Feign.builder() + .encoder(new MultipartFormEncoder()) + .target(UploadApi.class, "https://example.com"); + + client.upload( + Path.of("photo.png"), + List.of("tag1", "tag2"), + "value", + "text/plain" + ); + } +} +``` + +When `@Part` parameters are present, `MultipartFormEncoder` automatically sets the +`Content-Type: multipart/form-data` header with a generated boundary — no `@Headers` annotation needed. + +##### Sending files + +Sending files is straightforward: + +```java +interface FileUploadApi { + @RequestLine("POST /upload") + void upload( + // Content-Disposition and Content-Type headers are generated automatically from the File's name and extension + @Part("file") File file, + + // To override the filename and/or Content-Type auto-detection + @Part(headers = { + "Content-Disposition: form-data; name=\"custom\"; filename=\"custom.md\"", + "Content-Type: text/markdown" + }) + Path customFile, + + // To send InputStream (or String, byte[] etc.) as a file + @Part(headers = { + "Content-Disposition: form-data; name=\"stream\"; filename=\"{filename}\"", + "Content-Type: {contentType}" + }) + InputStream stream, + @Param("filename") String filename, + @Param("contentType") String contentType + ); +} + +public class Example { + public static void main(String[] args) { + FileUploadApi client = Feign.builder() + .encoder(new MultipartFormEncoder()) + .target(FileUploadApi.class, "https://example.com"); + + try (InputStream stream = new ByteArrayInputStream("Hello, world!".getBytes(StandardCharsets.UTF_8))) { + client.upload( + new File("photo.png"), + Path.of("my-file.txt"), + stream, + "hello.txt", + "text/plain" + ); + } + } +} +``` + +##### Encoding arbitrary part body types with `ConditionalEncoder` + +The `PartBodyFactory` accepts a chain of `Encoder` instances. Wrap each with `ConditionalEncoder` +and an `EncoderPredicate` to dispatch encoding based on the part's `Content-Type` header. +This works with any body type — JSON, XML, Protobuf, or custom formats: + +```java +interface UploadApi { + @RequestLine("POST /upload") + void upload( + @Part({ + "Content-Disposition: form-data; name=\"jsonPart\"", + "Content-Type: application/json" + }) + JsonPayload jsonPart, + + @Part({ + "Content-Disposition: form-data; name=\"xmlPart\"", + "Content-Type: application/xml" + }) + XmlPayload xmlPart + ); +} + +public class Example { + public static void main(String[] args) { + UploadApi client = Feign.builder() + .encoder(MultipartFormEncoder.builder() + .partBodyFactory(new PartBodyFactory(List.of( + new ConditionalEncoder(new GsonEncoder(), + EncoderPredicate.forContentType("application/json")), + new ConditionalEncoder(new JAXBEncoder(), + EncoderPredicate.forContentType("application/xml")), + new DefaultEncoder() // fallback for byte[], Path, InputStream etc. + ))) + .build()) + .target(UploadApi.class, "https://example.com"); + + client.upload(new JsonPayload(...), new XmlPayload(...)); + } +} +``` + +Each `ConditionalEncoder` only fires when its predicate matches the part's declared `Content-Type`, +falling through to the next encoder in the chain otherwise. The last encoder acts as a catch-all +for untyped parts (`Path`, `byte[]`, `File`, etc.). + ### Spring MultipartFile and Spring Cloud Netflix @FeignClient support You can also use Form Encoder with Spring `MultipartFile` and `@FeignClient`. @@ -1548,3 +1691,28 @@ public interface DownloadClient { } } ``` + +#### Streaming `MultipartFile` with `MultipartFileEncoder` (since v14) + +As an alternative to `SpringFormEncoder`, `MultipartFileEncoder` extends `MultipartFormEncoder` +to stream Spring `MultipartFile` objects directly — no buffering in memory: + +```java +@FeignClient( + name = "file-upload-service", + configuration = FileUploadServiceClient.MultipartSupportConfig.class +) +public interface FileUploadServiceClient extends IFileUploadServiceClient { + + public class MultipartSupportConfig { + + @Bean + public Encoder feignFormEncoder () { + return new MultipartFileEncoder(new MultipartFormEncoder()); + } + } +} +``` + +`MultipartFileEncoder` handles single `MultipartFile`, `MultipartFile[]`, and `Collection` +parameters, automatically generating `Content-Disposition` and `Content-Type` headers.