From 268f3c853eb51b20a6a45d5cd6a646ff8e57ff38 Mon Sep 17 00:00:00 2001 From: Olga MaciaszekSharma Date: Thu, 6 Jul 2023 19:07:40 +0200 Subject: [PATCH] Add RestTemplate support for HTTP interface client See gh-30117 --- .../ROOT/pages/integration/rest-clients.adoc | 16 +- .../client/support/RestTemplateAdapter.java | 136 +++++++++++ .../AbstractReactorHttpExchangeAdapter.java | 32 +-- .../service/invoker/HttpClientAdapter.java | 25 +- .../service/invoker/HttpExchangeAdapter.java | 9 +- .../service/invoker/HttpServiceMethod.java | 47 +++- .../invoker/HttpServiceProxyFactory.java | 5 +- .../invoker/ReactorHttpExchangeAdapter.java | 15 +- .../RestTemplateHttpServiceProxyTests.java | 220 ++++++++++++++++++ .../invoker/HttpClientServiceMethodTests.java | 39 ++++ ...HttpExchangeAdapterServiceMethodTests.java | 41 ++++ .../invoker/HttpServiceMethodTests.java | 177 +++----------- .../ReactiveHttpServiceMethodTests.java | 167 +++++++++++++ ...ExchangeAdapterHttpServiceMethodTests.java | 41 ++++ .../web/service/invoker/TestAdapter.java | 37 +++ .../invoker/TestHttpClientAdapter.java | 42 ++-- .../invoker/TestHttpExchangeAdapter.java | 106 +++++++++ ...KotlinRestTemplateHttpServiceProxyTests.kt | 218 +++++++++++++++++ .../invoker/HttpServiceMethodKotlinTests.kt | 117 +++++++--- .../client/support/WebClientAdapter.java | 6 +- .../WebClientHttpServiceProxyKotlinTests.kt | 3 +- 21 files changed, 1272 insertions(+), 227 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java create mode 100644 spring-web/src/test/java/org/springframework/web/client/support/RestTemplateHttpServiceProxyTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/HttpClientServiceMethodTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/HttpExchangeAdapterServiceMethodTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/ReactiveHttpServiceMethodTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/ReactorExchangeAdapterHttpServiceMethodTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/TestAdapter.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpExchangeAdapter.java create mode 100644 spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index f74abe6f29..0828d205fd 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -382,12 +382,24 @@ One, declare an interface with `@HttpExchange` methods: } ---- -Two, create a proxy that will perform the declared HTTP exchanges: +Two, create a proxy that will perform the declared HTTP exchanges, +either using `WebClient`: [source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.builder().baseUrl("https://api.github.com/").build(); - HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(client)).build(); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder().exchangeAdapter(WebClientAdapter.forClient(webClient)).build(); + + RepositoryService service = factory.createClient(RepositoryService.class); +---- + +or using `RestTemplate`: + +[source,java,indent=0,subs="verbatim,quotes"] +---- + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory("https://api.github.com/")); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder().exchangeAdapter(RestTemplateAdapter.forTemplate(restTemplate)).build(); RepositoryService service = factory.createClient(RepositoryService.class); ---- diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java new file mode 100644 index 0000000000..946006c080 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java @@ -0,0 +1,136 @@ +/* + * Copyright 2002-2023 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.client.support; + +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * An {@link HttpExchangeAdapter} that enables an {@link HttpServiceProxyFactory} to use + * {@link RestTemplate} for request execution. + *

+ * Use static factory methods in this class to create an {@link HttpServiceProxyFactory} + * configured with a given {@link RestTemplate}. + * + * @author Olga Maciaszek-Sharma + * @since 6.1 + */ +public final class RestTemplateAdapter implements HttpExchangeAdapter { + + private final RestTemplate restTemplate; + + // Private constructor; use static factory methods to instantiate + private RestTemplateAdapter(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @Override + public Void exchange(HttpRequestValues requestValues) { + this.restTemplate.exchange(newRequest(requestValues), Void.class); + return null; + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + return this.restTemplate.exchange(newRequest(requestValues), Void.class).getHeaders(); + } + + @Override + public T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return this.restTemplate.exchange(newRequest(requestValues), bodyType).getBody(); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + return this.restTemplate.exchange(newRequest(requestValues), Void.class); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + return this.restTemplate.exchange(newRequest(requestValues), bodyType); + } + + @Override + public boolean supportsRequestAttributes() { + return false; + } + + private RequestEntity newRequest(HttpRequestValues requestValues) { + URI uri; + if (requestValues.getUri() != null) { + uri = requestValues.getUri(); + } + else if (requestValues.getUriTemplate() != null) { + uri = this.restTemplate.getUriTemplateHandler().expand(requestValues.getUriTemplate(), + requestValues.getUriVariables()); + } + else { + throw new IllegalStateException("Neither full URL nor URI template"); + } + + HttpMethod httpMethod = requestValues.getHttpMethod(); + Assert.notNull(httpMethod, "HttpMethod is required"); + + RequestEntity.BodyBuilder builder = RequestEntity.method(httpMethod, uri) + .headers(requestValues.getHeaders()); + + if (!requestValues.getCookies().isEmpty()) { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + requestValues.getCookies() + .forEach((name, values) -> values.forEach(value -> + cookies.add(name, new HttpCookie(name, value)))); + + builder.header(HttpHeaders.COOKIE, + cookies.values() + .stream() + .flatMap(List::stream) + .map(HttpCookie::toString) + .collect(Collectors.joining("; "))); + } + + if (requestValues.getBodyValue() != null) { + return builder.body(requestValues.getBodyValue()); + } + return builder.build(); + } + + /** + * Create a {@link RestTemplateAdapter} for the given {@link RestTemplate} instance. + * @param restTemplate the {@link RestTemplate} to use + * @return the created adapter instance + */ + public static RestTemplateAdapter forTemplate(RestTemplate restTemplate) { + return new RestTemplateAdapter(restTemplate); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractReactorHttpExchangeAdapter.java b/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractReactorHttpExchangeAdapter.java index b675107ded..7bf5541ed9 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractReactorHttpExchangeAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractReactorHttpExchangeAdapter.java @@ -29,6 +29,10 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** + * A base reactive adapter that implements both {@link HttpClientAdapter} + * and {@link HttpExchangeAdapter}. Allows to ensure backwards compatibility + * with the deprecated {@link HttpClientAdapter} and handles blocking from reactive + * publishers to objects where necessary. * * @author Rossen Stoyanchev * @since 6.1 @@ -51,16 +55,16 @@ public abstract class AbstractReactorHttpExchangeAdapter /** - * - * @param reactiveAdapterRegistry + * Configure the registry for adapting various reactive types. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. */ public void setReactiveAdapterRegistry(ReactiveAdapterRegistry reactiveAdapterRegistry) { this.reactiveAdapterRegistry = reactiveAdapterRegistry; } /** - * - * @return + * Return the configured reactive type registry of adapters. */ @Override public ReactiveAdapterRegistry getReactiveAdapterRegistry() { @@ -68,31 +72,31 @@ public abstract class AbstractReactorHttpExchangeAdapter } /** - * - * @param blockTimeout + * Configure how long to block for the response of an HTTP service method with a + * synchronous (blocking) method signature. + *

+ * By default, this is not set, in which case the behavior depends on connection and + * request timeout settings of the underlying HTTP client. We recommend configuring + * timeout values directly on the underlying HTTP client, which provides more + * control over such settings. */ public void setBlockTimeout(@Nullable Duration blockTimeout) { this.blockTimeout = blockTimeout; } - /** - * - * @return - */ @Override @Nullable public Duration getBlockTimeout() { return this.blockTimeout; } - @Override - public void exchange(HttpRequestValues requestValues) { + public Void exchange(HttpRequestValues requestValues) { if (this.blockTimeout != null) { - exchangeForMono(requestValues).block(this.blockTimeout); + return exchangeForMono(requestValues).block(this.blockTimeout); } else { - exchangeForMono(requestValues).block(); + return exchangeForMono(requestValues).block(); } } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java index a7bb035bff..1b5aaf587b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java @@ -28,6 +28,7 @@ import org.springframework.http.ResponseEntity; * {@linkplain HttpServiceProxyFactory#createClient(Class) HTTP service proxy}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.0 * @deprecated in favor of {@link ReactorHttpExchangeAdapter} */ @@ -91,50 +92,58 @@ public interface HttpClientAdapter { /** * Adapt this {@link HttpClientAdapter} to {@link ReactorHttpExchangeAdapter}. - * @return + * @return a {@link ReactorHttpExchangeAdapter} instance created that delegating to + * the underlying {@link HttpClientAdapter} implementation * @since 6.1 */ default ReactorHttpExchangeAdapter asHttpExchangeAdapter() { + HttpClientAdapter delegate = this; + return new AbstractReactorHttpExchangeAdapter() { @Override public Mono exchangeForMono(HttpRequestValues requestValues) { - return requestToVoid(requestValues); + return delegate.requestToVoid(requestValues); } @Override public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { - return requestToHeaders(requestValues); + return delegate.requestToHeaders(requestValues); } @Override public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - return requestToBody(requestValues, bodyType); + return delegate.requestToBody(requestValues, bodyType); } @Override public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - return requestToBodyFlux(requestValues, bodyType); + return delegate.requestToBodyFlux(requestValues, bodyType); } @Override public Mono> exchangeForBodilessEntityMono(HttpRequestValues requestValues) { - return requestToBodilessEntity(requestValues); + return delegate.requestToBodilessEntity(requestValues); } @Override public Mono> exchangeForEntityMono( HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - return requestToEntity(requestValues, bodyType); + return delegate.requestToEntity(requestValues, bodyType); } @Override public Mono>> exchangeForEntityFlux( HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - return requestToEntityFlux(requestValues, bodyType); + return delegate.requestToEntityFlux(requestValues, bodyType); + } + + @Override + public boolean supportsRequestAttributes() { + return true; } }; } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapter.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapter.java index 67b169fc3b..cd354d87c1 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapter.java @@ -34,7 +34,7 @@ public interface HttpExchangeAdapter { * Perform the given request, and release the response content, if any. * @param requestValues the request to perform */ - void exchange(HttpRequestValues requestValues); + Void exchange(HttpRequestValues requestValues); /** * Perform the given request, release the response content, and return the @@ -48,8 +48,8 @@ public interface HttpExchangeAdapter { * Perform the given request and decode the response content to the given type. * @param requestValues the request to perform * @param bodyType the target type to decode to - * @return the decoded response. * @param the type the response is decoded to + * @return the decoded response. */ @Nullable T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType); @@ -66,4 +66,9 @@ public interface HttpExchangeAdapter { */ ResponseEntity exchangeForEntity(HttpRequestValues requestValues, ParameterizedTypeReference bodyType); + /** + * A flag that indicates whether request attributes are supported by a specific client + * adapter. + */ + boolean supportsRequestAttributes(); } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java index 1895f8416d..42b2f7515d 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java @@ -53,6 +53,7 @@ import org.springframework.web.service.annotation.HttpExchange; * * @author Rossen Stoyanchev * @author Sebastien Deleuze + * @author Olga Maciaszek-Sharma * @since 6.0 */ final class HttpServiceMethod { @@ -282,17 +283,57 @@ final class HttpServiceMethod { } - private record ExchangeResponseFunction( Function responseFunction) implements ResponseFunction { @Override public Object execute(HttpRequestValues requestValues) { - return null; + return this.responseFunction.apply(requestValues); } public static ResponseFunction create(HttpExchangeAdapter client, Method method) { - return new ExchangeResponseFunction(httpRequestValues -> null); + if (KotlinDetector.isSuspendingFunction(method)) { + throw new IllegalStateException("Kotlin Coroutines are only supported with reactive implementations"); + } + MethodParameter actualReturnParam = new MethodParameter(method, -1).nestedIfOptional(); + boolean returnOptional = actualReturnParam.getParameterType().equals(Optional.class); + Class actualReturnType = actualReturnParam.getNestedParameterType(); + + Function responseFunction; + if (actualReturnType.equals(void.class) || actualReturnType.equals(Void.class)) { + responseFunction = client::exchange; + } + else if (actualReturnType.equals(HttpHeaders.class)) { + responseFunction = request -> processResponse(client.exchangeForHeaders(request), + returnOptional); + } + else if (actualReturnType.equals(ResponseEntity.class)) { + MethodParameter bodyParam = actualReturnParam.nested(); + Class bodyType = bodyParam.getNestedParameterType(); + if (bodyType.equals(Void.class)) { + responseFunction = request -> processResponse(client + .exchangeForBodilessEntity(request), returnOptional); + } + else { + ParameterizedTypeReference bodyTypeReference = ParameterizedTypeReference + .forType(bodyParam.getNestedGenericParameterType()); + responseFunction = request -> processResponse(client.exchangeForEntity(request, + bodyTypeReference), returnOptional); + } + } + else { + ParameterizedTypeReference bodyTypeReference = ParameterizedTypeReference + .forType(actualReturnParam.getNestedGenericParameterType()); + responseFunction = request -> processResponse(client.exchangeForBody(request, + bodyTypeReference), returnOptional); + } + + return new ExchangeResponseFunction(responseFunction); + } + + private static @Nullable Object processResponse(@Nullable Object response, + boolean returnOptional) { + return returnOptional ? Optional.ofNullable(response) : response; } } 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 ff9ebf8572..bbf179307a 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 @@ -249,6 +249,7 @@ public final class HttpServiceProxyFactory { this.exchangeAdapter, initArgumentResolvers(), this.embeddedValueResolver); } + @SuppressWarnings("DataFlowIssue") private List initArgumentResolvers() { // Custom @@ -261,10 +262,12 @@ public final class HttpServiceProxyFactory { resolvers.add(new RequestHeaderArgumentResolver(service)); resolvers.add(new RequestBodyArgumentResolver()); resolvers.add(new PathVariableArgumentResolver(service)); + if (this.exchangeAdapter.supportsRequestAttributes()) { + resolvers.add(new RequestAttributeArgumentResolver()); + } resolvers.add(new RequestParamArgumentResolver(service)); resolvers.add(new RequestPartArgumentResolver()); resolvers.add(new CookieValueArgumentResolver(service)); - resolvers.add(new RequestAttributeArgumentResolver()); // Specific type resolvers.add(new UrlArgumentResolver()); diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapter.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapter.java index 7cd1bd0d3a..bad465b1f4 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapter.java @@ -25,6 +25,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; /** * Contract to abstract a Project Reactor, HTTP client to decouple it from the @@ -36,17 +37,23 @@ import org.springframework.http.ResponseEntity; public interface ReactorHttpExchangeAdapter extends HttpExchangeAdapter { /** - * - * @return + * Return the configured reactive type registry of adapters. */ ReactiveAdapterRegistry getReactiveAdapterRegistry(); /** + * Return the configured time to block for the response of an HTTP service method with + * a synchronous (blocking) method signature. * - * @return + *

+ * By default, this is not set, in which case the behavior depends on connection and + * request timeout settings of the underlying HTTP client. We recommend configuring + * timeout values directly on the underlying HTTP client, which provides more * + * control over such settings. */ + @Nullable Duration getBlockTimeout(); - + /** * Perform the given request, and release the response content, if any. * @param requestValues the request to perform diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestTemplateHttpServiceProxyTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestTemplateHttpServiceProxyTests.java new file mode 100644 index 0000000000..b8c382382f --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestTemplateHttpServiceProxyTests.java @@ -0,0 +1,220 @@ +/* + * Copyright 2002-2023 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.client.support; + +import java.io.IOException; +import java.net.URI; +import java.util.Optional; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.annotation.PutExchange; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.testfixture.servlet.MockMultipartFile; +import org.springframework.web.util.DefaultUriBuilderFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link HttpServiceProxyFactory HTTP Service proxy} using + * {@link RestTemplate} and {@link MockWebServer}. + * + * @author Olga Maciaszek-Sharma + */ +class RestTemplateHttpServiceProxyTests { + + private MockWebServer server; + + private TestService testService; + + @BeforeEach + void setUp() { + this.server = new MockWebServer(); + prepareResponse(); + this.testService = initTestService(); + } + + private TestService initTestService() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory(this.server.url("/").toString())); + return HttpServiceProxyFactory.builder() + .exchangeAdapter(RestTemplateAdapter.forTemplate(restTemplate)) + .build() + .createClient(TestService.class); + } + + @SuppressWarnings("ConstantConditions") + @AfterEach + void shutDown() throws IOException { + if (this.server != null) { + this.server.shutdown(); + } + } + + @Test + void getRequest() throws InterruptedException { + String response = testService.getRequest(); + + RecordedRequest request = this.server.takeRequest(); + assertThat(response).isEqualTo("Hello Spring!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/test"); + } + + @Test + void getRequestWithPathVariable() throws InterruptedException { + ResponseEntity response = testService.getRequestWithPathVariable("456"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("Hello Spring!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/test/456"); + } + + @Test + void getRequestWithDynamicUri() throws InterruptedException { + URI dynamicUri = this.server.url("/greeting/123").uri(); + + Optional response = testService.getRequestWithDynamicUri(dynamicUri, "456"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(response.orElse("empty")).isEqualTo("Hello Spring!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getRequestUrl().uri()).isEqualTo(dynamicUri); + } + + @Test + void postWithRequestHeader() throws InterruptedException { + testService.postRequestWithHeader("testHeader", "testBody"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/test"); + assertThat(request.getHeaders().get("testHeaderName")).isEqualTo("testHeader"); + assertThat(request.getBody().readUtf8()).isEqualTo("testBody"); + } + + @Test + void formData() throws Exception { + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("param1", "value 1"); + map.add("param2", "value 2"); + + testService.postForm(map); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getHeaders().get("Content-Type")) + .isEqualTo("application/x-www-form-urlencoded;charset=UTF-8"); + assertThat(request.getBody().readUtf8()).isEqualTo("param1=value+1¶m2=value+2"); + } + + @Test // gh-30342 + void multipart() throws InterruptedException { + String fileName = "testFileName"; + String originalFileName = "originalTestFileName"; + MultipartFile file = new MockMultipartFile(fileName, originalFileName, MediaType.APPLICATION_JSON_VALUE, + "test".getBytes()); + + testService.postMultipart(file, "test2"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getHeaders().get("Content-Type")).startsWith("multipart/form-data;boundary="); + assertThat(request.getBody().readUtf8()).containsSubsequence( + "Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", + "Content-Type: application/json", "Content-Length: 4", "test", + "Content-Disposition: form-data; name=\"anotherPart\"", "Content-Type: text/plain;charset=UTF-8", + "Content-Length: 5", "test2"); + } + + @Test + void putRequestWithCookies() throws InterruptedException { + testService.putRequestWithCookies("test1", "test2"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("PUT"); + assertThat(request.getHeader("Cookie")).isEqualTo("firstCookie=test1; secondCookie=test2"); + } + + @Test + void putRequestWithSameNameCookies() throws InterruptedException { + testService.putRequestWithSameNameCookies("test1", "test2"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("PUT"); + assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test1; testCookie=test2"); + } + + private void prepareResponse() { + MockResponse response = new MockResponse(); + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!"); + this.server.enqueue(response); + } + + private interface TestService { + + @GetExchange("/test") + String getRequest(); + + @GetExchange("/test/{id}") + ResponseEntity getRequestWithPathVariable(@PathVariable String id); + + @GetExchange("/test/{id}") + Optional getRequestWithDynamicUri(@Nullable URI uri, @PathVariable String id); + + @PostExchange("/test") + void postRequestWithHeader(@RequestHeader("testHeaderName") String testHeader, + @RequestBody String requestBody); + + @PostExchange(contentType = "application/x-www-form-urlencoded") + void postForm(@RequestParam MultiValueMap params); + + @PostExchange + void postMultipart(MultipartFile file, @RequestPart String anotherPart); + + @PutExchange + void putRequestWithCookies(@CookieValue String firstCookie, + @CookieValue String secondCookie); + + @PutExchange + void putRequestWithSameNameCookies(@CookieValue("testCookie") String firstCookie, + @CookieValue("testCookie") String secondCookie); + + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpClientServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpClientServiceMethodTests.java new file mode 100644 index 0000000000..3aa49de377 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpClientServiceMethodTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2023 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; + +/** + * Tests for {@link HttpServiceMethod} with a test {@link TestHttpClientAdapter} that + * stubs the client invocations. + *

+ * The tests do not create or invoke {@code HttpServiceMethod} directly but rather use + * {@link HttpServiceProxyFactory} to create a service proxy in order to use a strongly + * typed interface without the need for class casts. + * + * @author Olga Maciaszek-Sharma + */ +public class HttpClientServiceMethodTests extends ReactiveHttpServiceMethodTests { + + @BeforeEach + void setUp(){ + this.client = new TestHttpClientAdapter(); + this.proxyFactory = HttpServiceProxyFactory.builder((HttpClientAdapter) this.client).build(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpExchangeAdapterServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpExchangeAdapterServiceMethodTests.java new file mode 100644 index 0000000000..eb3e539a0b --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpExchangeAdapterServiceMethodTests.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2023 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; + +/** + * Tests for {@link HttpServiceMethod} with a blocking test {@link TestHttpExchangeAdapter} that + * stubs the client invocations. + *

+ * The tests do not create or invoke {@code HttpServiceMethod} directly but rather use + * {@link HttpServiceProxyFactory} to create a service proxy in order to use a strongly + * typed interface without the need for class casts. + * + * @author Olga Maciaszek-Sharma + */ +class HttpExchangeAdapterServiceMethodTests extends HttpServiceMethodTests { + + @BeforeEach + void setUp() { + this.client = new TestHttpExchangeAdapter(); + this.proxyFactory = HttpServiceProxyFactory.builder() + .exchangeAdapter((HttpExchangeAdapter) this.client) + .build(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java index 43b0d8c4c0..60e2b54b57 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java @@ -16,18 +16,12 @@ package org.springframework.web.service.invoker; +import java.util.List; import java.util.Optional; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; import org.junit.jupiter.api.Test; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -42,84 +36,25 @@ import static org.springframework.http.MediaType.APPLICATION_CBOR_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; /** - * Tests for {@link HttpServiceMethod} with a test {@link TestHttpClientAdapter} - * that stubs the client invocations. + * Base class for testing {@link HttpServiceMethod} with a test {@link TestHttpClientAdapter} + * and a test {@link TestHttpExchangeAdapter} that stub the client invocations. * - *

The tests do not create or invoke {@code HttpServiceMethod} directly but - * rather use {@link HttpServiceProxyFactory} to create a service proxy in order to - * use a strongly typed interface without the need for class casts. + *

+ * The tests do not create or invoke {@code HttpServiceMethod} directly but rather use + * {@link HttpServiceProxyFactory} to create a service proxy in order to use a strongly + * typed interface without the need for class casts. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma */ -class HttpServiceMethodTests { +abstract class HttpServiceMethodTests { - private static final ParameterizedTypeReference BODY_TYPE = new ParameterizedTypeReference<>() {}; + protected static final ParameterizedTypeReference BODY_TYPE = new ParameterizedTypeReference<>() { + }; - private final TestHttpClientAdapter client = new TestHttpClientAdapter(); + protected TestAdapter client; - private final HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builder(this.client).build(); - - - @Test - void reactorService() { - ReactorService service = this.proxyFactory.createClient(ReactorService.class); - - Mono voidMono = service.execute(); - StepVerifier.create(voidMono).verifyComplete(); - verifyClientInvocation("requestToVoid", null); - - Mono headersMono = service.getHeaders(); - StepVerifier.create(headersMono).expectNextCount(1).verifyComplete(); - verifyClientInvocation("requestToHeaders", null); - - Mono body = service.getBody(); - StepVerifier.create(body).expectNext("requestToBody").verifyComplete(); - verifyClientInvocation("requestToBody", BODY_TYPE); - - Flux fluxBody = service.getFluxBody(); - StepVerifier.create(fluxBody).expectNext("request", "To", "Body", "Flux").verifyComplete(); - verifyClientInvocation("requestToBodyFlux", BODY_TYPE); - - Mono> voidEntity = service.getVoidEntity(); - StepVerifier.create(voidEntity).expectNext(ResponseEntity.ok().build()).verifyComplete(); - verifyClientInvocation("requestToBodilessEntity", null); - - Mono> entity = service.getEntity(); - StepVerifier.create(entity).expectNext(ResponseEntity.ok("requestToEntity")); - verifyClientInvocation("requestToEntity", BODY_TYPE); - - Mono>> fluxEntity= service.getFluxEntity(); - StepVerifier.create(fluxEntity.flatMapMany(HttpEntity::getBody)).expectNext("request", "To", "Entity", "Flux").verifyComplete(); - verifyClientInvocation("requestToEntityFlux", BODY_TYPE); - - assertThat(service.getDefaultMethodValue()).isEqualTo("default value"); - } - - @Test - void rxJavaService() { - RxJavaService service = this.proxyFactory.createClient(RxJavaService.class); - Completable completable = service.execute(); - assertThat(completable).isNotNull(); - - Single headersSingle = service.getHeaders(); - assertThat(headersSingle.blockingGet()).isNotNull(); - - Single bodySingle = service.getBody(); - assertThat(bodySingle.blockingGet()).isEqualTo("requestToBody"); - - Flowable bodyFlow = service.getFlowableBody(); - assertThat(bodyFlow.toList().blockingGet()).asList().containsExactly("request", "To", "Body", "Flux"); - - Single> voidEntity = service.getVoidEntity(); - assertThat(voidEntity.blockingGet().getBody()).isNull(); - - Single> entitySingle = service.getEntity(); - assertThat(entitySingle.blockingGet().getBody()).isEqualTo("requestToEntity"); - - Single>> entityFlow = service.getFlowableEntity(); - Flowable body = (entityFlow.blockingGet()).getBody(); - assertThat(body.toList().blockingGet()).containsExactly("request", "To", "Entity", "Flux"); - } + protected HttpServiceProxyFactory proxyFactory; @Test void blockingService() { @@ -131,16 +66,19 @@ class HttpServiceMethodTests { assertThat(headers).isNotNull(); String body = service.getBody(); - assertThat(body).isEqualTo("requestToBody"); + assertThat(body).isEqualTo(client.getInvokedMethodReference()); Optional optional = service.getBodyOptional(); - assertThat(optional).contains("requestToBody"); + assertThat(optional).contains("body"); ResponseEntity entity = service.getEntity(); - assertThat(entity.getBody()).isEqualTo("requestToEntity"); + assertThat(entity.getBody()).isEqualTo("entity"); ResponseEntity voidEntity = service.getVoidEntity(); assertThat(voidEntity.getBody()).isNull(); + + List list = service.getList(); + assertThat(list.get(0)).isEqualTo("body"); } @Test @@ -166,9 +104,12 @@ class HttpServiceMethodTests { @Test void typeAndMethodAnnotatedService() { - HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builder(this.client) - .embeddedValueResolver(value -> (value.equals("${baseUrl}") ? "/base" : value)) - .build(); + HttpExchangeAdapter actualClient = this.client instanceof HttpClientAdapter httpClient + ? httpClient.asHttpExchangeAdapter() : (HttpExchangeAdapter) client; + HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builder() + .exchangeAdapter(actualClient) + .embeddedValueResolver(value -> (value.equals("${baseUrl}") ? "/base" : value)) + .build(); MethodLevelAnnotatedService service = proxyFactory.createClient(TypeAndMethodLevelAnnotatedService.class); @@ -189,68 +130,11 @@ class HttpServiceMethodTests { assertThat(requestValues.getHeaders().getAccept()).containsExactly(MediaType.APPLICATION_JSON); } - private void verifyClientInvocation(String methodName, @Nullable ParameterizedTypeReference expectedBodyType) { - assertThat(this.client.getInvokedMethodName()).isEqualTo(methodName); + protected void verifyClientInvocation(String methodName, @Nullable ParameterizedTypeReference expectedBodyType) { + assertThat(this.client.getInvokedMethodReference()).isEqualTo(methodName); assertThat(this.client.getBodyType()).isEqualTo(expectedBodyType); } - - @SuppressWarnings("unused") - private interface ReactorService { - - @GetExchange - Mono execute(); - - @GetExchange - Mono getHeaders(); - - @GetExchange - Mono getBody(); - - @GetExchange - Flux getFluxBody(); - - @GetExchange - Mono> getVoidEntity(); - - @GetExchange - Mono> getEntity(); - - @GetExchange - Mono>> getFluxEntity(); - - default String getDefaultMethodValue() { - return "default value"; - } - } - - - @SuppressWarnings("unused") - private interface RxJavaService { - - @GetExchange - Completable execute(); - - @GetExchange - Single getHeaders(); - - @GetExchange - Single getBody(); - - @GetExchange - Flowable getFlowableBody(); - - @GetExchange - Single> getVoidEntity(); - - @GetExchange - Single> getEntity(); - - @GetExchange - Single>> getFlowableEntity(); - } - - @SuppressWarnings("unused") private interface BlockingService { @@ -271,8 +155,11 @@ class HttpServiceMethodTests { @GetExchange ResponseEntity getEntity(); - } + @GetExchange + List getList(); + + } @SuppressWarnings("unused") private interface MethodLevelAnnotatedService { @@ -285,10 +172,10 @@ class HttpServiceMethodTests { } - @SuppressWarnings("unused") @HttpExchange(url = "${baseUrl}", contentType = APPLICATION_CBOR_VALUE, accept = APPLICATION_CBOR_VALUE) private interface TypeAndMethodLevelAnnotatedService extends MethodLevelAnnotatedService { + } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/ReactiveHttpServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/ReactiveHttpServiceMethodTests.java new file mode 100644 index 0000000000..6b2ea90408 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/ReactiveHttpServiceMethodTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2023 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 io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.service.annotation.GetExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Base class for testing reactive scenarios in {@link HttpServiceMethod} with + * a test {@link TestHttpClientAdapter} and a test {@link TestHttpExchangeAdapter} + * that stub the client invocations. + * + *

+ * The tests do not create or invoke {@code HttpServiceMethod} directly but rather use + * {@link HttpServiceProxyFactory} to create a service proxy in order to use a strongly + * typed interface without the need for class casts. + * + * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma + */ +abstract class ReactiveHttpServiceMethodTests extends HttpServiceMethodTests { + + @Test + void reactorService() { + ReactorService service = proxyFactory.createClient(ReactorService.class); + + Mono voidMono = service.execute(); + StepVerifier.create(voidMono).verifyComplete(); + verifyClientInvocation("void", null); + + Mono headersMono = service.getHeaders(); + StepVerifier.create(headersMono).expectNextCount(1).verifyComplete(); + verifyClientInvocation("headers", null); + + Mono body = service.getBody(); + StepVerifier.create(body).expectNext("body").verifyComplete(); + verifyClientInvocation("body", BODY_TYPE); + + Flux fluxBody = service.getFluxBody(); + StepVerifier.create(fluxBody).expectNext("request", "To", "Body", "Flux").verifyComplete(); + verifyClientInvocation("bodyFlux", BODY_TYPE); + + Mono> voidEntity = service.getVoidEntity(); + StepVerifier.create(voidEntity).expectNext(ResponseEntity.ok().build()).verifyComplete(); + verifyClientInvocation("bodilessEntity", null); + + Mono> entity = service.getEntity(); + StepVerifier.create(entity).expectNext(ResponseEntity.ok("requestToEntity")); + verifyClientInvocation("entity", BODY_TYPE); + + Mono>> fluxEntity = service.getFluxEntity(); + StepVerifier.create(fluxEntity.flatMapMany(HttpEntity::getBody)) + .expectNext("request", "To", "Entity", "Flux") + .verifyComplete(); + verifyClientInvocation("entityFlux", BODY_TYPE); + + assertThat(service.getDefaultMethodValue()).isEqualTo("default value"); + } + + @Test + void rxJavaService() { + RxJavaService service = this.proxyFactory.createClient(RxJavaService.class); + Completable completable = service.execute(); + assertThat(completable).isNotNull(); + + Single headersSingle = service.getHeaders(); + assertThat(headersSingle.blockingGet()).isNotNull(); + + Single bodySingle = service.getBody(); + assertThat(bodySingle.blockingGet()).isEqualTo("body"); + + Flowable bodyFlow = service.getFlowableBody(); + assertThat(bodyFlow.toList().blockingGet()).asList().containsExactly("request", "To", "Body", "Flux"); + + Single> voidEntity = service.getVoidEntity(); + assertThat(voidEntity.blockingGet().getBody()).isNull(); + + Single> entitySingle = service.getEntity(); + assertThat(entitySingle.blockingGet().getBody()).isEqualTo("entity"); + + Single>> entityFlow = service.getFlowableEntity(); + Flowable body = (entityFlow.blockingGet()).getBody(); + assertThat(body.toList().blockingGet()).containsExactly("request", "To", "Entity", "Flux"); + } + + private interface ReactorService { + + @GetExchange + Mono execute(); + + @GetExchange + Mono getHeaders(); + + @GetExchange + Mono getBody(); + + @GetExchange + Flux getFluxBody(); + + @GetExchange + Mono> getVoidEntity(); + + @GetExchange + Mono> getEntity(); + + @GetExchange + Mono>> getFluxEntity(); + + default String getDefaultMethodValue() { + return "default value"; + } + + } + + @SuppressWarnings("unused") + private interface RxJavaService { + + @GetExchange + Completable execute(); + + @GetExchange + Single getHeaders(); + + @GetExchange + Single getBody(); + + @GetExchange + Flowable getFlowableBody(); + + @GetExchange + Single> getVoidEntity(); + + @GetExchange + Single> getEntity(); + + @GetExchange + Single>> getFlowableEntity(); + + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/ReactorExchangeAdapterHttpServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/ReactorExchangeAdapterHttpServiceMethodTests.java new file mode 100644 index 0000000000..c9a5b8d552 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/ReactorExchangeAdapterHttpServiceMethodTests.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2023 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; + +/** + * Tests for {@link HttpServiceMethod} with an {@link HttpExchangeAdapter} + * build from a test {@link TestHttpClientAdapter} that stubs the client invocations. + *

+ * The tests do not create or invoke {@code HttpServiceMethod} directly but rather use + * {@link HttpServiceProxyFactory} to create a service proxy in order to use a strongly + * typed interface without the need for class casts. + * + * @author Olga Maciaszek-Sharma + */ +public class ReactorExchangeAdapterHttpServiceMethodTests extends ReactiveHttpServiceMethodTests { + + @BeforeEach + void setUp() { + this.client = new TestHttpClientAdapter(); + this.proxyFactory = HttpServiceProxyFactory.builder() + .exchangeAdapter(((HttpClientAdapter) this.client).asHttpExchangeAdapter()) + .build(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/TestAdapter.java b/spring-web/src/test/java/org/springframework/web/service/invoker/TestAdapter.java new file mode 100644 index 0000000000..eb9ab78cd5 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/TestAdapter.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2023 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.springframework.core.ParameterizedTypeReference; +import org.springframework.lang.Nullable; + +/** + * A helper interface for verifying method invoked on {@link HttpExchangeAdapter} + * and {@link HttpClientAdapter}, as well as their values. + * + * @author Olga Maciaszek-Sharma + */ +interface TestAdapter { + + String getInvokedMethodReference(); + + HttpRequestValues getRequestValues(); + + @Nullable + ParameterizedTypeReference getBodyType(); + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpClientAdapter.java b/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpClientAdapter.java index 9f7f176766..70eadec4d0 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpClientAdapter.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -16,6 +16,8 @@ package org.springframework.web.service.invoker; +import java.util.Collections; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -33,10 +35,10 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Olga Maciaszek-Sharma */ @SuppressWarnings("unchecked") -class TestHttpClientAdapter implements HttpClientAdapter { +class TestHttpClientAdapter implements HttpClientAdapter, TestAdapter { @Nullable - private String invokedMethodName; + private String invokedForReturnMethodReference; @Nullable private HttpRequestValues requestValues; @@ -44,17 +46,19 @@ class TestHttpClientAdapter implements HttpClientAdapter { @Nullable private ParameterizedTypeReference bodyType; - - public String getInvokedMethodName() { - assertThat(this.invokedMethodName).isNotNull(); - return this.invokedMethodName; + @Override + public String getInvokedMethodReference() { + assertThat(this.invokedForReturnMethodReference).isNotNull(); + return this.invokedForReturnMethodReference; } + @Override public HttpRequestValues getRequestValues() { assertThat(this.requestValues).isNotNull(); return this.requestValues; } + @Override @Nullable public ParameterizedTypeReference getBodyType() { return this.bodyType; @@ -65,31 +69,33 @@ class TestHttpClientAdapter implements HttpClientAdapter { @Override public Mono requestToVoid(HttpRequestValues requestValues) { - saveInput("requestToVoid", requestValues, null); + saveInput("void", requestValues, null); return Mono.empty(); } @Override public Mono requestToHeaders(HttpRequestValues requestValues) { - saveInput("requestToHeaders", requestValues, null); + saveInput("headers", requestValues, null); return Mono.just(new HttpHeaders()); } @Override public Mono requestToBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - saveInput("requestToBody", requestValues, bodyType); - return (Mono) Mono.just(getInvokedMethodName()); + saveInput("body", requestValues, bodyType); + return bodyType.getType().getTypeName().contains("List") ? + (Mono) Mono.just(Collections.singletonList(getInvokedMethodReference())) + : (Mono) Mono.just(getInvokedMethodReference()); } @Override public Flux requestToBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - saveInput("requestToBodyFlux", requestValues, bodyType); + saveInput("bodyFlux", requestValues, bodyType); return (Flux) Flux.just("request", "To", "Body", "Flux"); } @Override public Mono> requestToBodilessEntity(HttpRequestValues requestValues) { - saveInput("requestToBodilessEntity", requestValues, null); + saveInput("bodilessEntity", requestValues, null); return Mono.just(ResponseEntity.ok().build()); } @@ -97,22 +103,22 @@ class TestHttpClientAdapter implements HttpClientAdapter { public Mono> requestToEntity( HttpRequestValues requestValues, ParameterizedTypeReference type) { - saveInput("requestToEntity", requestValues, type); - return Mono.just((ResponseEntity) ResponseEntity.ok("requestToEntity")); + saveInput("entity", requestValues, type); + return Mono.just((ResponseEntity) ResponseEntity.ok("entity")); } @Override public Mono>> requestToEntityFlux( HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { - saveInput("requestToEntityFlux", requestValues, bodyType); + saveInput("entityFlux", requestValues, bodyType); return Mono.just(ResponseEntity.ok((Flux) Flux.just("request", "To", "Entity", "Flux"))); } private void saveInput( - String methodName, HttpRequestValues requestValues, @Nullable ParameterizedTypeReference bodyType) { + String reference, HttpRequestValues requestValues, @Nullable ParameterizedTypeReference bodyType) { - this.invokedMethodName = methodName; + this.invokedForReturnMethodReference = reference; this.requestValues = requestValues; this.bodyType = bodyType; } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpExchangeAdapter.java b/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpExchangeAdapter.java new file mode 100644 index 0000000000..cf112152b3 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/TestHttpExchangeAdapter.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2023 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 java.util.Collections; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link HttpExchangeAdapter} with stubbed responses. + * + * @author Olga Maciaszek-Sharma + */ +@SuppressWarnings("unchecked") +public class TestHttpExchangeAdapter implements HttpExchangeAdapter, TestAdapter { + + @Nullable + private String invokedMethodName; + + @Nullable + private HttpRequestValues requestValues; + + @Nullable + private ParameterizedTypeReference bodyType; + + public String getInvokedMethodReference() { + assertThat(this.invokedMethodName).isNotNull(); + return this.invokedMethodName; + } + + public HttpRequestValues getRequestValues() { + assertThat(this.requestValues).isNotNull(); + return this.requestValues; + } + + @Override + @Nullable + public ParameterizedTypeReference getBodyType() { + return this.bodyType; + } + + @Override + public Void exchange(HttpRequestValues requestValues) { + saveInput("void", requestValues, null); + return null; + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + saveInput("headers", requestValues, null); + return new HttpHeaders(); + } + + @Override + public T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + saveInput("body", requestValues, bodyType); + return bodyType.getType().getTypeName().contains("List") + ? (T) Collections.singletonList(getInvokedMethodReference()) : (T) getInvokedMethodReference(); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + saveInput("bodilessEntity", requestValues, null); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + saveInput("entity", requestValues, bodyType); + return (ResponseEntity) ResponseEntity.ok(this.getInvokedMethodReference()); + } + + @Override + public boolean supportsRequestAttributes() { + return false; + } + + private void saveInput(String methodName, HttpRequestValues requestValues, + @Nullable ParameterizedTypeReference bodyType) { + + this.invokedMethodName = methodName; + this.requestValues = requestValues; + this.bodyType = bodyType; + } + +} diff --git a/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt b/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt new file mode 100644 index 0000000000..8d3e36f38a --- /dev/null +++ b/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2002-2023 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.client.support + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.lang.Nullable +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.bind.annotation.* +import org.springframework.web.client.RestTemplate +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.PostExchange +import org.springframework.web.service.annotation.PutExchange +import org.springframework.web.service.invoker.HttpServiceProxyFactory +import org.springframework.web.testfixture.servlet.MockMultipartFile +import org.springframework.web.util.DefaultUriBuilderFactory +import java.net.URI +import java.util.* + +/** + * Kotlin integration tests for {@link HttpServiceProxyFactory HTTP Service proxy} using + * {@link RestTemplate} and {@link MockWebServer}. + * + * @author Olga Maciaszek-Sharma + */ +class KotlinRestTemplateHttpServiceProxyTests { + + private lateinit var server: MockWebServer + + private lateinit var testService: TestService + + @BeforeEach + fun setUp() { + server = MockWebServer() + prepareResponse() + testService = initTestService() + } + + private fun initTestService(): TestService { + val restTemplate = RestTemplate() + restTemplate.uriTemplateHandler = DefaultUriBuilderFactory(server.url("/").toString()) + return HttpServiceProxyFactory.builder() + .exchangeAdapter(RestTemplateAdapter.forTemplate(restTemplate)) + .build() + .createClient(TestService::class.java) + } + + @AfterEach + fun shutDown() { + server.shutdown() + } + + @Test + @Throws(InterruptedException::class) + fun getRequest() { + val response = testService.request + + val request = server.takeRequest() + assertThat(response).isEqualTo("Hello Spring!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/test") + } + + @Test + @Throws(InterruptedException::class) + fun getRequestWithPathVariable() { + val response = testService.getRequestWithPathVariable("456") + + val request = server.takeRequest() + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.body).isEqualTo("Hello Spring!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/test/456") + } + + @Test + @Throws(InterruptedException::class) + fun getRequestWithDynamicUri() { + val dynamicUri = server.url("/greeting/123").uri() + + val response = testService.getRequestWithDynamicUri(dynamicUri, "456") + + val request = server.takeRequest() + assertThat(response.orElse("empty")).isEqualTo("Hello Spring!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.requestUrl.uri()).isEqualTo(dynamicUri) + } + + @Test + @Throws(InterruptedException::class) + fun postWithRequestHeader() { + testService.postRequestWithHeader("testHeader", "testBody") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/test") + assertThat(request.headers["testHeaderName"]).isEqualTo("testHeader") + assertThat(request.body.readUtf8()).isEqualTo("testBody") + } + + @Test + @Throws(Exception::class) + fun formData() { + val map: MultiValueMap = LinkedMultiValueMap() + map.add("param1", "value 1") + map.add("param2", "value 2") + + testService.postForm(map) + + val request = server.takeRequest() + assertThat(request.headers["Content-Type"]) + .isEqualTo("application/x-www-form-urlencoded;charset=UTF-8") + assertThat(request.body.readUtf8()).isEqualTo("param1=value+1¶m2=value+2") + } + + // gh-30342 + @Test + @Throws(InterruptedException::class) + fun multipart() { + val fileName = "testFileName" + val originalFileName = "originalTestFileName" + val file: MultipartFile = MockMultipartFile(fileName, originalFileName, MediaType.APPLICATION_JSON_VALUE, + "test".toByteArray()) + + testService.postMultipart(file, "test2") + + val request = server.takeRequest() + assertThat(request.headers["Content-Type"]).startsWith("multipart/form-data;boundary=") + assertThat(request.body.readUtf8()).containsSubsequence( + "Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", + "Content-Type: application/json", "Content-Length: 4", "test", + "Content-Disposition: form-data; name=\"anotherPart\"", "Content-Type: text/plain;charset=UTF-8", + "Content-Length: 5", "test2") + } + + @Test + @Throws(InterruptedException::class) + fun putRequestWithCookies() { + testService.putRequestWithCookies("test1", "test2") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.getHeader("Cookie")) + .isEqualTo("firstCookie=test1; secondCookie=test2") + } + + @Test + @Throws(InterruptedException::class) + fun putRequestWithSameNameCookies() { + testService.putRequestWithSameNameCookies("test1", "test2") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.getHeader("Cookie")) + .isEqualTo("testCookie=test1; testCookie=test2") + } + + private fun prepareResponse() { + val response = MockResponse() + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!") + server.enqueue(response) + } + + + private interface TestService { + + @get:GetExchange("/test") + val request: String + + @GetExchange("/test/{id}") + fun getRequestWithPathVariable(@PathVariable id: String): ResponseEntity + + @GetExchange("/test/{id}") + fun getRequestWithDynamicUri(@Nullable uri: URI, @PathVariable id: String): Optional + + @PostExchange("/test") + fun postRequestWithHeader(@RequestHeader("testHeaderName") testHeader: String, + @RequestBody requestBody: String) + + @PostExchange(contentType = "application/x-www-form-urlencoded") + fun postForm(@RequestParam params: MultiValueMap) + + @PostExchange + fun postMultipart(file: MultipartFile, @RequestPart anotherPart: String) + + @PutExchange + fun putRequestWithCookies(@CookieValue firstCookie: String, + @CookieValue secondCookie: String) + + @PutExchange + fun putRequestWithSameNameCookies(@CookieValue("testCookie") firstCookie: String, + @CookieValue("testCookie") secondCookie: String) + } + +} \ No newline at end of file diff --git a/spring-web/src/test/kotlin/org/springframework/web/service/invoker/HttpServiceMethodKotlinTests.kt b/spring-web/src/test/kotlin/org/springframework/web/service/invoker/HttpServiceMethodKotlinTests.kt index 1fdb1a092e..c42c824d36 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/service/invoker/HttpServiceMethodKotlinTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/service/invoker/HttpServiceMethodKotlinTests.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalStateException import org.junit.jupiter.api.Test import org.springframework.core.ParameterizedTypeReference import org.springframework.http.HttpStatus @@ -30,64 +31,124 @@ import org.springframework.web.service.annotation.GetExchange * Kotlin tests for [HttpServiceMethod]. * * @author Sebastien Deleuze + * @author Olga Maciaszek-Sharma */ -class HttpServiceMethodKotlinTests { +@Suppress("DEPRECATION") +class KotlinHttpServiceMethodTests { - private val client = TestHttpClientAdapter() - private val proxyFactory = HttpServiceProxyFactory.builder(client).build() + private val webClientAdapter = TestHttpClientAdapter() + private val httpExchangeAdapter = TestHttpExchangeAdapter() + private val proxyFactory = HttpServiceProxyFactory.builder(webClientAdapter).build() + private val blockingProxyFactory = HttpServiceProxyFactory.builder() + .exchangeAdapter(httpExchangeAdapter).build() @Test fun coroutinesService(): Unit = runBlocking { - val service = proxyFactory.createClient(CoroutinesService::class.java) + val service = proxyFactory.createClient(FunctionsService::class.java) val stringBody = service.stringBody() - assertThat(stringBody).isEqualTo("requestToBody") - verifyClientInvocation("requestToBody", object : ParameterizedTypeReference() {}) + assertThat(stringBody).isEqualTo("body") + verifyClientInvocation("body", object : ParameterizedTypeReference() {}) service.listBody() - verifyClientInvocation("requestToBody", object : ParameterizedTypeReference>() {}) + verifyClientInvocation("body", object : ParameterizedTypeReference>() {}) val flowBody = service.flowBody() assertThat(flowBody.toList()).containsExactly("request", "To", "Body", "Flux") - verifyClientInvocation("requestToBodyFlux", object : ParameterizedTypeReference() {}) + verifyClientInvocation("bodyFlux", object : ParameterizedTypeReference() {}) val stringEntity = service.stringEntity() - assertThat(stringEntity).isEqualTo(ResponseEntity.ok("requestToEntity")) - verifyClientInvocation("requestToEntity", object : ParameterizedTypeReference() {}) + assertThat(stringEntity).isEqualTo(ResponseEntity.ok("entity")) + verifyClientInvocation("entity", object : ParameterizedTypeReference() {}) service.listEntity() - verifyClientInvocation("requestToEntity", object : ParameterizedTypeReference>() {}) + verifyClientInvocation("entity", object : ParameterizedTypeReference>() {}) val flowEntity = service.flowEntity() assertThat(flowEntity.statusCode).isEqualTo(HttpStatus.OK) assertThat(flowEntity.body!!.toList()).containsExactly("request", "To", "Entity", "Flux") - verifyClientInvocation("requestToEntityFlux", object : ParameterizedTypeReference() {}) + verifyClientInvocation("entityFlux", object : ParameterizedTypeReference() {}) } - private fun verifyClientInvocation(methodName: String, expectedBodyType: ParameterizedTypeReference<*>) { - assertThat(client.invokedMethodName).isEqualTo(methodName) - assertThat(client.bodyType).isEqualTo(expectedBodyType) + @Test + fun blockingServiceWithExchangeResponseFunction() { + val service = blockingProxyFactory.createClient(BlockingFunctionsService::class.java) + + val stringBody = service.stringBodyBlocking() + assertThat(stringBody).isEqualTo("body") + verifyTemplateInvocation("body", object : ParameterizedTypeReference() {}) + + val listBody = service.listBodyBlocking() + assertThat(listBody.size).isEqualTo(1) + verifyTemplateInvocation("body", object : ParameterizedTypeReference>() {}) + + val stringEntity = service.stringEntityBlocking() + assertThat(stringEntity).isEqualTo(ResponseEntity.ok("entity")) + verifyTemplateInvocation("entity", object : ParameterizedTypeReference() {}) + + service.listEntityBlocking() + verifyTemplateInvocation("entity", object : ParameterizedTypeReference>() {}) + } + + @Test + fun coroutineServiceWithExchangeResponseFunction() { + assertThatIllegalStateException().isThrownBy { + blockingProxyFactory.createClient(FunctionsService::class.java) + } + + assertThatIllegalStateException().isThrownBy { + blockingProxyFactory.createClient(SuspendingFunctionsService::class.java) + } + } + + private fun verifyTemplateInvocation(methodReference: String, expectedBodyType: ParameterizedTypeReference<*>) { + assertThat(httpExchangeAdapter.invokedMethodReference).isEqualTo(methodReference) + assertThat(httpExchangeAdapter.bodyType).isEqualTo(expectedBodyType) + } + + private fun verifyClientInvocation(methodReference: String, expectedBodyType: ParameterizedTypeReference<*>) { + assertThat(webClientAdapter.invokedMethodReference).isEqualTo(methodReference) + assertThat(webClientAdapter.bodyType).isEqualTo(expectedBodyType) } - private interface CoroutinesService { - - @GetExchange - suspend fun stringBody(): String - - @GetExchange - suspend fun listBody(): MutableList + private interface FunctionsService : SuspendingFunctionsService { @GetExchange fun flowBody(): Flow - @GetExchange - suspend fun stringEntity(): ResponseEntity - - @GetExchange - suspend fun listEntity(): ResponseEntity> - @GetExchange fun flowEntity(): ResponseEntity> } + private interface SuspendingFunctionsService : BlockingFunctionsService { + + @GetExchange + suspend fun stringBody(): String + + @GetExchange + suspend fun listBody(): MutableList + + @GetExchange + suspend fun stringEntity(): ResponseEntity + + @GetExchange + suspend fun listEntity(): ResponseEntity> + } + + private interface BlockingFunctionsService { + + @GetExchange + fun stringBodyBlocking(): String + + @GetExchange + fun listBodyBlocking(): MutableList + + @GetExchange + fun stringEntityBlocking(): ResponseEntity + + @GetExchange + fun listEntityBlocking(): ResponseEntity> + + } + } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java index 2cb0f12ecc..2a9c99f4e6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java @@ -25,10 +25,10 @@ import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.invoker.AbstractReactorHttpExchangeAdapter; import org.springframework.web.service.invoker.HttpRequestValues; import org.springframework.web.service.invoker.HttpServiceProxyFactory; import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; -import org.springframework.web.service.invoker.AbstractReactorHttpExchangeAdapter; /** * {@link ReactorHttpExchangeAdapter} that enables an {@link HttpServiceProxyFactory} @@ -132,4 +132,8 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { return new WebClientAdapter(webClient); } + @Override + public boolean supportsRequestAttributes() { + return true; + } } diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt index ac9354cc27..9292ade029 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt @@ -41,7 +41,8 @@ import java.util.function.Consumer * @author DongHyeon Kim * @author Sebastien Deleuze */ -class WebClientHttpServiceProxyKotlinTests { +@Suppress("DEPRECATION") +class KotlinWebClientHttpServiceProxyTests { private lateinit var server: MockWebServer