diff --git a/framework-docs/src/docs/asciidoc/integration.adoc b/framework-docs/src/docs/asciidoc/integration.adoc index 13e167bb49..63b3272188 100644 --- a/framework-docs/src/docs/asciidoc/integration.adoc +++ b/framework-docs/src/docs/asciidoc/integration.adoc @@ -453,6 +453,11 @@ method parameters: parameters are encoded in the request body. Otherwise, they are added as URL query parameters. +| `@RequestPart` +| Add a request part, which may be a String (form field), `Resource` (file part), + Object (entity to be encoded, e.g. as JSON), `HttpEntity` (part content and headers), + a Spring `Part`, or Reactive Streams `Publisher` of any of the above. + | `@CookieValue` | Add a cookie or mutliple cookies. The argument may be a `Map` or `MultiValueMap` with multiple cookies, a `Collection` of values, or an diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java index 9f63e77f04..a5bac7d1f7 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java @@ -24,6 +24,7 @@ import java.lang.annotation.Target; import org.springframework.aot.hint.annotation.Reflective; import org.springframework.core.annotation.AliasFor; +import org.springframework.http.HttpEntity; import org.springframework.web.bind.annotation.Mapping; /** @@ -92,6 +93,17 @@ import org.springframework.web.bind.annotation.Mapping; * RequestParamArgumentResolver} * * + * {@link org.springframework.web.bind.annotation.RequestPart @RequestPart} + * Add a request part, which may be a String (form field), + * {@link org.springframework.core.io.Resource} (file part), Object (entity to be + * encoded, e.g. as JSON), {@link HttpEntity} (part content and headers), a + * {@link org.springframework.http.codec.multipart.Part}, or a + * {@link org.reactivestreams.Publisher} of any of the above. + * ( + * {@link org.springframework.web.service.invoker.RequestPartArgumentResolver + * RequestPartArgumentResolver} + * + * * {@link org.springframework.web.bind.annotation.CookieValue @CookieValue} * Add a cookie * {@link org.springframework.web.service.invoker.CookieValueArgumentResolver diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java index b03088ced5..96ef73a9d6 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java @@ -29,9 +29,11 @@ import java.util.function.Function; import org.reactivestreams.Publisher; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.http.codec.FormHttpMessageWriter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -222,6 +224,9 @@ public final class HttpRequestValues { @Nullable private MultiValueMap requestParams; + @Nullable + private MultipartBodyBuilder multipartBuilder; + @Nullable private Map attributes; @@ -335,6 +340,26 @@ public final class HttpRequestValues { return this; } + /** + * Add a part to a multipart request. The part value may be as described + * in {@link MultipartBodyBuilder#part(String, Object)}. + */ + public Builder addRequestPart(String name, Object part) { + this.multipartBuilder = (this.multipartBuilder != null ? this.multipartBuilder : new MultipartBodyBuilder()); + this.multipartBuilder.part(name, part); + return this; + } + + /** + * Variant of {@link #addRequestPart(String, Object)} that allows the + * part value to be produced by a {@link Publisher}. + */ + public > Builder addRequestPart(String name, P publisher, ResolvableType type) { + this.multipartBuilder = (this.multipartBuilder != null ? this.multipartBuilder : new MultipartBodyBuilder()); + this.multipartBuilder.asyncPart(name, publisher, ParameterizedTypeReference.forType(type.getType())); + return this; + } + /** * Configure an attribute to associate with the request. * @param name the attribute name @@ -399,6 +424,10 @@ public final class HttpRequestValues { uriTemplate = appendQueryParams(uriTemplate, uriVars, this.requestParams); } } + else if (this.multipartBuilder != null) { + Assert.isTrue(bodyValue == null && this.body == null, "Expected body or request parts, not both"); + bodyValue = this.multipartBuilder.build(); + } HttpHeaders headers = HttpHeaders.EMPTY; if (this.headers != null) { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java index f078396389..61e98f63dd 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java @@ -323,6 +323,7 @@ public final class HttpServiceProxyFactory implements InitializingBean, Embedded resolvers.add(new RequestBodyArgumentResolver(this.reactiveAdapterRegistry)); resolvers.add(new PathVariableArgumentResolver(service)); resolvers.add(new RequestParamArgumentResolver(service)); + resolvers.add(new RequestPartArgumentResolver(this.reactiveAdapterRegistry)); resolvers.add(new CookieValueArgumentResolver(service)); resolvers.add(new RequestAttributeArgumentResolver()); @@ -497,6 +498,7 @@ public final class HttpServiceProxyFactory implements InitializingBean, Embedded resolvers.add(new RequestBodyArgumentResolver(this.reactiveAdapterRegistry)); resolvers.add(new PathVariableArgumentResolver(conversionService)); resolvers.add(new RequestParamArgumentResolver(conversionService)); + resolvers.add(new RequestPartArgumentResolver(this.reactiveAdapterRegistry)); resolvers.add(new CookieValueArgumentResolver(conversionService)); resolvers.add(new RequestAttributeArgumentResolver()); diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java new file mode 100644 index 0000000000..b6e025fe9e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java @@ -0,0 +1,81 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.web.service.invoker; + +import org.reactivestreams.Publisher; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpEntity; +import org.springframework.http.codec.multipart.Part; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestPart; + +/** + * {@link HttpServiceArgumentResolver} for {@link RequestPart @RequestPart} + * annotated arguments. + * + *

The argument may be: + *

    + *
  • String -- form field + *
  • {@link org.springframework.core.io.Resource Resource} -- file part + *
  • Object -- content to be encoded (e.g. to JSON) + *
  • {@link HttpEntity} -- part content and headers although generally it's + * easier to add headers through the returned builder + *
  • {@link Part} -- a part from a server request + *
  • {@link Publisher} of any of the above + *
+ * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public class RequestPartArgumentResolver extends AbstractNamedValueArgumentResolver { + + private final ReactiveAdapterRegistry reactiveAdapterRegistry; + + + public RequestPartArgumentResolver(ReactiveAdapterRegistry reactiveAdapterRegistry) { + this.reactiveAdapterRegistry = reactiveAdapterRegistry; + } + + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + RequestPart annot = parameter.getParameterAnnotation(RequestPart.class); + return (annot == null ? null : + new NamedValueInfo(annot.name(), annot.required(), null, "request part", true)); + } + + @Override + protected void addRequestValue( + String name, Object value, MethodParameter parameter, HttpRequestValues.Builder requestValues) { + + Class type = parameter.getParameterType(); + ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(type); + if (adapter != null) { + Assert.isTrue(!adapter.isNoValue(), "Expected publisher that produces a value"); + Publisher publisher = adapter.toPublisher(value); + requestValues.addRequestPart(name, publisher, ResolvableType.forMethodParameter(parameter.nested())); + } + else { + requestValues.addRequestPart(name, value); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestPartArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestPartArgumentResolverTests.java new file mode 100644 index 0000000000..2caf71a246 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestPartArgumentResolverTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.web.service.invoker; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.service.annotation.PostExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link RequestPartArgumentResolver}. + * + *

Additional tests for this resolver: + *

    + *
  • Base class functionality in {@link NamedValueArgumentResolverTests} + *
  • Form data vs query params in {@link HttpRequestValuesTests} + *
+ * + * @author Rossen Stoyanchev + */ +public class RequestPartArgumentResolverTests { + + private final TestHttpClientAdapter client = new TestHttpClientAdapter(); + + private Service service; + + + @BeforeEach + void setUp() throws Exception { + HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builder(this.client).build(); + this.service = proxyFactory.createClient(Service.class); + } + + + // Base class functionality should be tested in NamedValueArgumentResolverTests. + // Form data vs query params tested in HttpRequestValuesTests. + + @Test + void requestPart() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + HttpEntity part2 = new HttpEntity<>("part 2", headers); + this.service.postMultipart("part 1", part2, Mono.just("part 3")); + + Object body = this.client.getRequestValues().getBodyValue(); + assertThat(body).isNotNull().isInstanceOf(MultiValueMap.class); + MultiValueMap> map = (MultiValueMap>) body; + + assertThat(map.getFirst("part1").getBody()).isEqualTo("part 1"); + assertThat(map.getFirst("part2")).isEqualTo(part2); + assertThat(((Mono) map.getFirst("part3").getBody()).block()).isEqualTo("part 3"); + } + + + private interface Service { + + @PostExchange + void postMultipart(@RequestPart String part1, @RequestPart HttpEntity part2, @RequestPart Mono part3); + + } + +}