diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index a85c846e94c..14f12899de0 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -956,6 +956,11 @@ method parameters: | `URI` | Dynamically set the URL for the request, overriding the annotation's `url` attribute. +| `UriBuilderFactory` +| Provide a `UriBuilderFactory` to use to expand the `UriTemplate`. + Allows dynamically setting the base URI for the request, + while maintaining the `path` specified through annotations. + | `HttpMethod` | Dynamically set the HTTP method for the request, overriding the annotation's `method` attribute diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java index daaa7ad9d72..bea86fb3ae5 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -16,6 +16,7 @@ package org.springframework.web.client.support; +import java.net.URI; import java.util.ArrayList; import java.util.List; @@ -92,9 +93,19 @@ public final class RestClientAdapter implements HttpExchangeAdapter { if (values.getUri() != null) { bodySpec = uriSpec.uri(values.getUri()); } + else if (values.getUriTemplate() != null) { - bodySpec = uriSpec.uri(values.getUriTemplate(), values.getUriVariables()); + if (values.getUriBuilderFactory() != null) { + URI expanded = values.getUriBuilderFactory() + .expand(values.getUriTemplate(), values.getUriVariables()); + bodySpec = uriSpec.uri(expanded); + } + + else { + bodySpec = uriSpec.uri(values.getUriTemplate(), values.getUriVariables()); + } } + else { throw new IllegalStateException("Neither full URL nor URI template"); } 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 index 84df6a46f96..72064cdb968 100644 --- 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 @@ -16,6 +16,7 @@ package org.springframework.web.client.support; +import java.net.URI; import java.util.ArrayList; import java.util.List; @@ -91,9 +92,19 @@ public final class RestTemplateAdapter implements HttpExchangeAdapter { if (values.getUri() != null) { builder = RequestEntity.method(httpMethod, values.getUri()); } + else if (values.getUriTemplate() != null) { - builder = RequestEntity.method(httpMethod, values.getUriTemplate(), values.getUriVariables()); + if (values.getUriBuilderFactory() != null) { + URI expanded = values.getUriBuilderFactory() + .expand(values.getUriTemplate(), values.getUriVariables()); + builder = RequestEntity.method(httpMethod, expanded); + } + + else { + builder = RequestEntity.method(httpMethod, values.getUriTemplate(), values.getUriVariables()); + } } + else { throw new IllegalStateException("Neither full URL nor URI template"); } 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 a5bac7d1f7c..ec747db4c11 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 @@ -26,6 +26,7 @@ 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; +import org.springframework.web.util.UriBuilderFactory; /** * Annotation to declare a method on an HTTP service interface as an HTTP @@ -61,6 +62,13 @@ import org.springframework.web.bind.annotation.Mapping; * {@link org.springframework.web.service.invoker.UrlArgumentResolver} * * + * {@link UriBuilderFactory} + * Dynamically set the {@code base URI} for the request, overriding the + * one from the annotation's {@link #url()} attribute, while keeping the + * subsequent path segments as defined there + * {@link org.springframework.web.service.invoker.UriBuilderFactoryArgumentResolver} + * + * * {@link org.springframework.http.HttpMethod HttpMethod} * Dynamically set the HTTP method for the request, overriding the annotation's * {@link #method()} attribute 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 85a75d34a73..7281cf44576 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 @@ -36,6 +36,7 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriBuilderFactory; import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriUtils; @@ -46,6 +47,7 @@ import org.springframework.web.util.UriUtils; * {@link HttpExchangeAdapter} to adapt to the underlying HTTP client. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.0 */ public class HttpRequestValues { @@ -63,6 +65,9 @@ public class HttpRequestValues { @Nullable private final String uriTemplate; + @Nullable + private final UriBuilderFactory uriBuilderFactory; + private final Map uriVariables; private final HttpHeaders headers; @@ -75,8 +80,27 @@ public class HttpRequestValues { private final Object bodyValue; + /** + * Construct {@link HttpRequestValues}. + * + * @deprecated in favour of {@link HttpRequestValues#HttpRequestValues( + * HttpMethod, URI, String, UriBuilderFactory, Map, HttpHeaders, + * MultiValueMap, Map, Object)} to be removed in 6.2. + */ + @Deprecated(since = "6.1", forRemoval = true) + protected HttpRequestValues(@Nullable HttpMethod httpMethod, + @Nullable URI uri, @Nullable String uriTemplate, + Map uriVariables, + HttpHeaders headers, MultiValueMap cookies, Map attributes, + @Nullable Object bodyValue) { + + this(httpMethod, uri, uriTemplate, null, uriVariables, + headers, cookies, attributes, bodyValue); + } + protected HttpRequestValues(@Nullable HttpMethod httpMethod, - @Nullable URI uri, @Nullable String uriTemplate, Map uriVariables, + @Nullable URI uri, @Nullable String uriTemplate, + @Nullable UriBuilderFactory uriBuilderFactory, Map uriVariables, HttpHeaders headers, MultiValueMap cookies, Map attributes, @Nullable Object bodyValue) { @@ -85,6 +109,7 @@ public class HttpRequestValues { this.httpMethod = httpMethod; this.uri = uri; this.uriTemplate = uriTemplate; + this.uriBuilderFactory = uriBuilderFactory; this.uriVariables = uriVariables; this.headers = headers; this.cookies = cookies; @@ -106,7 +131,6 @@ public class HttpRequestValues { *

Typically, this comes from a {@link URI} method argument, which provides * the caller with the option to override the {@link #getUriTemplate() * uriTemplate} from class and method {@code HttpExchange} annotations. - * annotation. */ @Nullable public URI getUri() { @@ -122,6 +146,19 @@ public class HttpRequestValues { return this.uriTemplate; } + /** + * Return the {@link UriBuilderFactory} to expand + * the {@link HttpRequestValues#uriTemplate} with. + *

This comes from a {@link UriBuilderFactory} method argument. + * It allows you to override the {@code baseUri}, while keeping the + * path as defined in class and method + * {@code HttpExchange} annotations. + */ + @Nullable + public UriBuilderFactory getUriBuilderFactory() { + return this.uriBuilderFactory; + } + /** * Return the URL template variables, or an empty map. */ @@ -202,6 +239,9 @@ public class HttpRequestValues { @Nullable private String uriTemplate; + @Nullable + private UriBuilderFactory uriBuilderFactory; + @Nullable private Map uriVars; @@ -249,6 +289,15 @@ public class HttpRequestValues { return this; } + /** + * Set the {@link UriBuilderFactory} that + * will be used to expand the URI. + */ + public Builder setUriBuilderFactory(@Nullable UriBuilderFactory uriBuilderFactory) { + this.uriBuilderFactory = uriBuilderFactory; + return this; + } + /** * Add a URI variable name-value pair. */ @@ -379,6 +428,7 @@ public class HttpRequestValues { URI uri = this.uri; String uriTemplate = (this.uriTemplate != null ? this.uriTemplate : ""); + UriBuilderFactory uriBuilderFactory = this.uriBuilderFactory; Map uriVars = (this.uriVars != null ? new HashMap<>(this.uriVars) : Collections.emptyMap()); Object bodyValue = this.bodyValue; @@ -420,7 +470,8 @@ public class HttpRequestValues { new HashMap<>(this.attributes) : Collections.emptyMap()); return createRequestValues( - this.httpMethod, uri, uriTemplate, uriVars, headers, cookies, attributes, bodyValue); + this.httpMethod, uri, uriTemplate, uriBuilderFactory, + uriVars, headers, cookies, attributes, bodyValue); } protected boolean hasParts() { @@ -459,14 +510,37 @@ public class HttpRequestValues { return uriComponentsBuilder.build().toUriString(); } + /** + * Create {@link HttpRequestValues} from values passed to the {@link Builder}. + * @deprecated in favour of {@link Builder#createRequestValues( + * HttpMethod, URI, String, UriBuilderFactory, Map, HttpHeaders, + * MultiValueMap, Map, Object)} to be removed in 6.2. + */ + @Deprecated(since = "6.1", forRemoval = true) + protected HttpRequestValues createRequestValues( + @Nullable HttpMethod httpMethod, + @Nullable URI uri, @Nullable String uriTemplate, + Map uriVars, + HttpHeaders headers, MultiValueMap cookies, Map attributes, + @Nullable Object bodyValue) { + + return createRequestValues(httpMethod, uri, uriTemplate, null, + uriVars, headers, cookies, attributes, bodyValue); + } + + /** + * Create {@link HttpRequestValues} from values passed to the {@link Builder}. + */ protected HttpRequestValues createRequestValues( @Nullable HttpMethod httpMethod, - @Nullable URI uri, @Nullable String uriTemplate, Map uriVars, + @Nullable URI uri, @Nullable String uriTemplate, + @Nullable UriBuilderFactory uriBuilderFactory, Map uriVars, HttpHeaders headers, MultiValueMap cookies, Map attributes, @Nullable Object bodyValue) { return new HttpRequestValues( - this.httpMethod, uri, uriTemplate, uriVars, headers, cookies, attributes, bodyValue); + this.httpMethod, uri, uriTemplate, uriBuilderFactory, + uriVars, headers, cookies, attributes, bodyValue); } } 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 2e18cef4833..6737aef14fe 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 @@ -273,6 +273,7 @@ public final class HttpServiceProxyFactory { // Specific type resolvers.add(new UrlArgumentResolver()); + resolvers.add(new UriBuilderFactoryArgumentResolver()); resolvers.add(new HttpMethodArgumentResolver()); return resolvers; diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java index 9a6f3410409..182cd2a1830 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java @@ -31,11 +31,13 @@ import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriBuilderFactory; /** * {@link HttpRequestValues} extension for use with {@link ReactorHttpExchangeAdapter}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.1 */ public final class ReactiveHttpRequestValues extends HttpRequestValues { @@ -49,11 +51,13 @@ public final class ReactiveHttpRequestValues extends HttpRequestValues { private ReactiveHttpRequestValues( @Nullable HttpMethod httpMethod, - @Nullable URI uri, @Nullable String uriTemplate, Map uriVariables, + @Nullable URI uri, @Nullable String uriTemplate, + @Nullable UriBuilderFactory uriBuilderFactory, Map uriVariables, HttpHeaders headers, MultiValueMap cookies, Map attributes, @Nullable Object bodyValue, @Nullable Publisher body, @Nullable ParameterizedTypeReference elementType) { - super(httpMethod, uri, uriTemplate, uriVariables, headers, cookies, attributes, bodyValue); + super(httpMethod, uri, uriTemplate, uriBuilderFactory, + uriVariables, headers, cookies, attributes, bodyValue); this.body = body; this.bodyElementType = elementType; @@ -136,6 +140,12 @@ public final class ReactiveHttpRequestValues extends HttpRequestValues { return this; } + @Override + public Builder setUriBuilderFactory(UriBuilderFactory uriBuilderFactory) { + super.setUriBuilderFactory(uriBuilderFactory); + return this; + } + @Override public Builder setUriVariable(String name, String value) { super.setUriVariable(name, value); @@ -261,12 +271,14 @@ public final class ReactiveHttpRequestValues extends HttpRequestValues { @Override protected ReactiveHttpRequestValues createRequestValues( @Nullable HttpMethod httpMethod, - @Nullable URI uri, @Nullable String uriTemplate, Map uriVars, + @Nullable URI uri, @Nullable String uriTemplate, + @Nullable UriBuilderFactory uriBuilderFactory, Map uriVars, HttpHeaders headers, MultiValueMap cookies, Map attributes, @Nullable Object bodyValue) { return new ReactiveHttpRequestValues( - httpMethod, uri, uriTemplate, uriVars, headers, cookies, attributes, + httpMethod, uri, uriTemplate, uriBuilderFactory, + uriVars, headers, cookies, attributes, bodyValue, this.body, this.bodyElementType); } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java new file mode 100644 index 00000000000..d5d40f0bad7 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java @@ -0,0 +1,55 @@ +/* + * 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.net.URL; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.web.util.UriBuilderFactory; +import org.springframework.web.util.UriTemplate; + +/** + * An {@link HttpServiceArgumentResolver} that uses the provided + * {@link UriBuilderFactory} to expand the {@link UriTemplate}. + *

Unlike with the {@link UrlArgumentResolver}, + * if the {@link UriBuilderFactoryArgumentResolver} is provided, + * it will not override the entire {@link URL}, but just the {@code baseUri}. + *

This allows for dynamically setting the {@code baseUri}, + * while keeping the {@code path} specified through class + * and method annotations. + * + * @author Olga Maciaszek-Sharma + * @since 6.1 + */ +public class UriBuilderFactoryArgumentResolver implements HttpServiceArgumentResolver { + + @Override + public boolean resolve( + @Nullable Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { + + if (!parameter.getParameterType().equals(UriBuilderFactory.class)) { + return false; + } + + if (argument != null) { + requestValues.setUriBuilderFactory((UriBuilderFactory) argument); + } + + return true; + } +} diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index b149c302d41..dfc04156696 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -16,6 +16,7 @@ package org.springframework.web.client.support; +import java.io.IOException; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -30,6 +31,7 @@ import io.micrometer.observation.tck.TestObservationRegistryAssert; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -55,6 +57,7 @@ import org.springframework.web.service.invoker.HttpExchangeAdapter; import org.springframework.web.service.invoker.HttpServiceProxyFactory; import org.springframework.web.testfixture.servlet.MockMultipartFile; import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.UriBuilderFactory; import static org.assertj.core.api.Assertions.assertThat; @@ -69,6 +72,16 @@ import static org.assertj.core.api.Assertions.assertThat; @SuppressWarnings("JUnitMalformedDeclaration") class RestClientAdapterTests { + private final MockWebServer anotherServer = anotherServer(); + + @SuppressWarnings("ConstantValue") + @AfterEach + void shutdown() throws IOException { + if (this.anotherServer != null) { + this.anotherServer.shutdown(); + } + } + @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @ParameterizedTest @@ -203,6 +216,62 @@ class RestClientAdapterTests { assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test1; testCookie=test2"); } + @ParameterizedAdapterTest + void getWithUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { + UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/") + .toString()); + + ResponseEntity actualResponse = service.getWithUriBuilderFactory(factory); + + RecordedRequest request = this.anotherServer.takeRequest(); + assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(actualResponse.getBody()).isEqualTo("Hello Spring 2!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(server.getRequestCount()).isEqualTo(0); + } + + @ParameterizedAdapterTest + void getWithFactoryPathVariableAndRequestParam(MockWebServer server, Service service) throws InterruptedException { + UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/") + .toString()); + + ResponseEntity actualResponse = service.getWithUriBuilderFactory(factory, "123", + "test"); + + RecordedRequest request = this.anotherServer.takeRequest(); + assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(actualResponse.getBody()).isEqualTo("Hello Spring 2!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/greeting/123?param=test"); + assertThat(server.getRequestCount()).isEqualTo(0); + } + + @ParameterizedAdapterTest + void getWithIgnoredUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { + URI dynamicUri = server.url("/greeting/123").uri(); + UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/") + .toString()); + + ResponseEntity actualResponse = service.getWithIgnoredUriBuilderFactory(dynamicUri, factory); + + RecordedRequest request = server.takeRequest(); + assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(actualResponse.getBody()).isEqualTo("Hello Spring!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/greeting/123"); + assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); + } + + + private static MockWebServer anotherServer() { + MockWebServer anotherServer = new MockWebServer(); + MockResponse response = new MockResponse(); + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"); + anotherServer.enqueue(response); + return anotherServer; + } + private interface Service { @@ -231,6 +300,15 @@ class RestClientAdapterTests { void putWithSameNameCookies( @CookieValue("testCookie") String firstCookie, @CookieValue("testCookie") String secondCookie); + @GetExchange("/greeting") + ResponseEntity getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory); + + @GetExchange("/greeting/{id}") + ResponseEntity getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory, + @PathVariable String id, @RequestParam String param); + + @GetExchange("/greeting") + ResponseEntity getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory); } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java index 40692cc27ae..bc6f4e2f867 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java @@ -45,6 +45,7 @@ class HttpRequestValuesTests { assertThat(requestValues.getUri()).isNull(); assertThat(requestValues.getUriTemplate()).isEmpty(); + assertThat(requestValues.getUriBuilderFactory()).isNull(); } @ParameterizedTest diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java new file mode 100644 index 00000000000..ab19a4f40c7 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java @@ -0,0 +1,72 @@ +/* + * 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.Test; + +import org.springframework.lang.Nullable; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.UriBuilderFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link UriBuilderFactoryArgumentResolver}. + * + * @author Olga Maciaszek-Sharma + */ +class UriBuilderFactoryArgumentResolverTests { + + private final TestExchangeAdapter client = new TestExchangeAdapter(); + + private final Service service = + HttpServiceProxyFactory.builderFor(this.client).build() + .createClient(Service.class); + + @Test + void uriBuilderFactory(){ + UriBuilderFactory factory = new DefaultUriBuilderFactory("https://example.com"); + this.service.execute(factory); + + assertThat(getRequestValues().getUriBuilderFactory()).isEqualTo(factory); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + assertThat(getRequestValues().getUri()).isNull(); + } + + @Test + void ignoreNullUriBuilderFactory(){ + this.service.execute(null); + + assertThat(getRequestValues().getUriBuilderFactory()).isEqualTo(null); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + assertThat(getRequestValues().getUri()).isNull(); + } + + + private HttpRequestValues getRequestValues() { + return this.client.getRequestValues(); + } + + + private interface Service { + + @GetExchange("/path") + void execute(@Nullable UriBuilderFactory uri); + + } +} 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 index 82bba493ea6..4248b27ae33 100644 --- 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 @@ -37,6 +37,7 @@ 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 org.springframework.web.util.UriBuilderFactory import java.net.URI import java.util.* @@ -52,10 +53,13 @@ class KotlinRestTemplateHttpServiceProxyTests { private lateinit var testService: TestService + private lateinit var anotherServer: MockWebServer + @BeforeEach fun setUp() { server = MockWebServer() prepareResponse() + anotherServer = anotherServer() testService = initTestService() } @@ -71,6 +75,7 @@ class KotlinRestTemplateHttpServiceProxyTests { @AfterEach fun shutDown() { server.shutdown() + anotherServer.shutdown() } @Test @@ -178,12 +183,73 @@ class KotlinRestTemplateHttpServiceProxyTests { .isEqualTo("testCookie=test1; testCookie=test2") } + @Test + @Throws(InterruptedException::class) + fun getWithUriBuilderFactory() { + val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/") + .toString()) + + val actualResponse: ResponseEntity = testService + .getWithUriBuilderFactory(factory) + + val request = anotherServer.takeRequest() + assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) + assertThat(actualResponse.body).isEqualTo("Hello Spring 2!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/greeting") + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + @Throws(InterruptedException::class) + fun getWithFactoryPathVariableAndRequestParam() { + val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/") + .toString()) + + val actualResponse: ResponseEntity = testService + .getWithUriBuilderFactory(factory, "123", + "test") + + val request = anotherServer.takeRequest() + assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) + assertThat(actualResponse.body).isEqualTo("Hello Spring 2!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/greeting/123?param=test") + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + @Throws(InterruptedException::class) + fun getWithIgnoredUriBuilderFactory() { + val dynamicUri = server.url("/greeting/123").uri() + val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/") + .toString()) + + val actualResponse: ResponseEntity = testService + .getWithIgnoredUriBuilderFactory(dynamicUri, factory) + + val request = server.takeRequest() + assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) + assertThat(actualResponse.body).isEqualTo("Hello Spring!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/greeting/123") + assertThat(anotherServer.requestCount).isEqualTo(0) + } + + private fun prepareResponse() { val response = MockResponse() response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!") server.enqueue(response) } + private fun anotherServer(): MockWebServer { + val anotherServer = MockWebServer() + val response = MockResponse() + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!") + anotherServer.enqueue(response) + return anotherServer + } private interface TestService { @@ -213,6 +279,16 @@ class KotlinRestTemplateHttpServiceProxyTests { @PutExchange fun putRequestWithSameNameCookies(@CookieValue("testCookie") firstCookie: String, @CookieValue("testCookie") secondCookie: String) + + @GetExchange("/greeting") + fun getWithUriBuilderFactory(uriBuilderFactory: UriBuilderFactory?): ResponseEntity + + @GetExchange("/greeting/{id}") + fun getWithUriBuilderFactory(uriBuilderFactory: UriBuilderFactory?, + @PathVariable id: String?, @RequestParam param: String?): ResponseEntity + + @GetExchange("/greeting") + fun getWithIgnoredUriBuilderFactory(uri: URI?, uriBuilderFactory: UriBuilderFactory?): ResponseEntity } } \ No newline at end of file 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 1459a4805e6..b8f297c232b 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 @@ -16,6 +16,8 @@ package org.springframework.web.reactive.function.client.support; +import java.net.URI; + import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -96,32 +98,41 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { } @SuppressWarnings("ReactiveStreamsUnusedPublisher") - private WebClient.RequestBodySpec newRequest(HttpRequestValues requestValues) { + private WebClient.RequestBodySpec newRequest(HttpRequestValues values) { - HttpMethod httpMethod = requestValues.getHttpMethod(); + HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); WebClient.RequestBodyUriSpec uriSpec = this.webClient.method(httpMethod); WebClient.RequestBodySpec bodySpec; - if (requestValues.getUri() != null) { - bodySpec = uriSpec.uri(requestValues.getUri()); + if (values.getUri() != null) { + bodySpec = uriSpec.uri(values.getUri()); } - else if (requestValues.getUriTemplate() != null) { - bodySpec = uriSpec.uri(requestValues.getUriTemplate(), requestValues.getUriVariables()); + + else if (values.getUriTemplate() != null) { + if(values.getUriBuilderFactory() != null){ + URI expanded = values.getUriBuilderFactory() + .expand(values.getUriTemplate(), values.getUriVariables()); + bodySpec = uriSpec.uri(expanded); + } + + else { + bodySpec = uriSpec.uri(values.getUriTemplate(), values.getUriVariables()); + } } else { throw new IllegalStateException("Neither full URL nor URI template"); } - bodySpec.headers(headers -> headers.putAll(requestValues.getHeaders())); - bodySpec.cookies(cookies -> cookies.putAll(requestValues.getCookies())); - bodySpec.attributes(attributes -> attributes.putAll(requestValues.getAttributes())); + bodySpec.headers(headers -> headers.putAll(values.getHeaders())); + bodySpec.cookies(cookies -> cookies.putAll(values.getCookies())); + bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); - if (requestValues.getBodyValue() != null) { - bodySpec.bodyValue(requestValues.getBodyValue()); + if (values.getBodyValue() != null) { + bodySpec.bodyValue(values.getBodyValue()); } - else if (requestValues instanceof ReactiveHttpRequestValues reactiveRequestValues) { + else if (values instanceof ReactiveHttpRequestValues reactiveRequestValues) { Publisher body = reactiveRequestValues.getBodyPublisher(); if (body != null) { ParameterizedTypeReference elementType = reactiveRequestValues.getBodyPublisherElementType(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java index 52981b73bf5..c8d0c696e0a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java @@ -47,6 +47,8 @@ import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.PostExchange; import org.springframework.web.service.invoker.HttpServiceProxyFactory; import org.springframework.web.testfixture.servlet.MockMultipartFile; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.UriBuilderFactory; import static org.assertj.core.api.Assertions.assertThat; @@ -60,12 +62,17 @@ import static org.assertj.core.api.Assertions.assertThat; */ public class WebClientAdapterTests { + private static final String ANOTHER_SERVER_RESPONSE_BODY = "Hello Spring 2!"; + private MockWebServer server; + private MockWebServer anotherServer; + @BeforeEach void setUp() { this.server = new MockWebServer(); + this.anotherServer = anotherServer(); } @SuppressWarnings("ConstantConditions") @@ -74,6 +81,10 @@ public class WebClientAdapterTests { if (this.server != null) { this.server.shutdown(); } + + if (this.anotherServer != null) { + this.anotherServer.shutdown(); + } } @@ -157,6 +168,60 @@ public class WebClientAdapterTests { "Content-Type: text/plain;charset=UTF-8", "Content-Length: 5", "test2"); } + @Test + void uriBuilderFactory() throws Exception { + String ignoredResponseBody = "hello"; + prepareResponse(response -> response.setResponseCode(200).setBody(ignoredResponseBody)); + UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/") + .toString()); + + String actualBody = initService().getWithUriBuilderFactory(factory); + + assertThat(actualBody).isEqualTo(ANOTHER_SERVER_RESPONSE_BODY); + assertThat(this.anotherServer.takeRequest().getPath()).isEqualTo("/greeting"); + assertThat(this.server.getRequestCount()).isEqualTo(0); + } + + @Test + void uriBuilderFactoryWithPathVariableAndRequestParam() throws Exception { + String ignoredResponseBody = "hello"; + prepareResponse(response -> response.setResponseCode(200).setBody(ignoredResponseBody)); + UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/") + .toString()); + + String actualBody = initService().getWithUriBuilderFactory(factory, "123", "test"); + + assertThat(actualBody).isEqualTo(ANOTHER_SERVER_RESPONSE_BODY); + assertThat(this.anotherServer.takeRequest().getPath()) + .isEqualTo("/greeting/123?param=test"); + assertThat(this.server.getRequestCount()).isEqualTo(0); + } + + @Test + void ignoredUriBuilderFactory() throws Exception { + String expectedResponseBody = "hello"; + prepareResponse(response -> response.setResponseCode(200).setBody(expectedResponseBody)); + URI dynamicUri = this.server.url("/greeting/123").uri(); + UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/") + .toString()); + + String actualBody = initService().getWithIgnoredUriBuilderFactory(dynamicUri, factory); + + assertThat(actualBody).isEqualTo(expectedResponseBody); + assertThat(this.server.takeRequest().getRequestUrl().uri()).isEqualTo(dynamicUri); + assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); + } + + + private static MockWebServer anotherServer() { + MockWebServer anotherServer = new MockWebServer(); + MockResponse response = new MockResponse(); + response.setHeader("Content-Type", "text/plain") + .setBody(ANOTHER_SERVER_RESPONSE_BODY); + anotherServer.enqueue(response); + return anotherServer; + } + private Service initService() { WebClient webClient = WebClient.builder().baseUrl(this.server.url("/").toString()).build(); return initService(webClient); @@ -191,6 +256,16 @@ public class WebClientAdapterTests { @PostExchange void postMultipart(MultipartFile file, @RequestPart String anotherPart); + @GetExchange("/greeting") + String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory); + + @GetExchange("/greeting/{id}") + String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory, + @PathVariable String id, @RequestParam String param); + + @GetExchange("/greeting") + String getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory); + } } 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 32c48f8f342..abecdadc560 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 @@ -22,15 +22,22 @@ 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.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestAttribute +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.reactive.function.client.ClientRequest import org.springframework.web.reactive.function.client.ExchangeFunction import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.service.annotation.GetExchange import org.springframework.web.service.invoker.HttpServiceProxyFactory import org.springframework.web.service.invoker.createClient +import org.springframework.web.util.DefaultUriBuilderFactory +import org.springframework.web.util.UriBuilderFactory import reactor.core.publisher.Mono import reactor.test.StepVerifier +import java.net.URI import java.time.Duration import java.util.function.Consumer @@ -40,20 +47,24 @@ import java.util.function.Consumer * * @author DongHyeon Kim * @author Sebastien Deleuze + * @author Olga Maciaszek-Sharma */ -@Suppress("DEPRECATION") class KotlinWebClientHttpServiceProxyTests { private lateinit var server: MockWebServer + private lateinit var anotherServer: MockWebServer + @BeforeEach fun setUp() { server = MockWebServer() + anotherServer = anotherServer() } @AfterEach fun shutdown() { server.shutdown() + anotherServer.shutdown() } @Test @@ -120,6 +131,55 @@ class KotlinWebClientHttpServiceProxyTests { } } + @Test + @Throws(InterruptedException::class) + fun getWithFactoryPathVariableAndRequestParam() { + prepareResponse { response: MockResponse -> + response.setHeader( + "Content-Type", + "text/plain" + ).setBody("Hello Spring!") + } + val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/") + .toString()) + + val actualResponse: ResponseEntity = initHttpService() + .getWithUriBuilderFactory(factory, "123", + "test") + + val request = anotherServer.takeRequest() + assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) + assertThat(actualResponse.body).isEqualTo("Hello Spring 2!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/greeting/123?param=test") + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + @Throws(InterruptedException::class) + fun getWithIgnoredUriBuilderFactory() { + prepareResponse { response: MockResponse -> + response.setHeader( + "Content-Type", + "text/plain" + ).setBody("Hello Spring!") + } + val dynamicUri = server.url("/greeting/123").uri() + val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/") + .toString()) + + val actualResponse: ResponseEntity = initHttpService() + .getWithIgnoredUriBuilderFactory(dynamicUri, factory) + + val request = server.takeRequest() + assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) + assertThat(actualResponse.body).isEqualTo("Hello Spring!") + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/greeting/123") + assertThat(anotherServer.requestCount).isEqualTo(0) + } + + private fun initHttpService(): TestHttpService { val webClient = WebClient.builder().baseUrl( server.url("/").toString() @@ -138,6 +198,14 @@ class KotlinWebClientHttpServiceProxyTests { server.enqueue(response) } + private fun anotherServer(): MockWebServer { + val anotherServer = MockWebServer() + val response = MockResponse() + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!") + anotherServer.enqueue(response) + return anotherServer + } + private interface TestHttpService { @GetExchange("/greeting") suspend fun getGreetingSuspending(): String @@ -150,5 +218,12 @@ class KotlinWebClientHttpServiceProxyTests { @GetExchange("/greeting") suspend fun getGreetingSuspendingWithAttribute(@RequestAttribute myAttribute: String): String + + @GetExchange("/greeting/{id}") + fun getWithUriBuilderFactory(uriBuilderFactory: UriBuilderFactory?, + @PathVariable id: String?, @RequestParam param: String?): ResponseEntity + + @GetExchange("/greeting") + fun getWithIgnoredUriBuilderFactory(uri: URI?, uriBuilderFactory: UriBuilderFactory?): ResponseEntity } }