diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index f73d22e1a059..e20fdb52bc1e 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -394,13 +394,24 @@ either using `WebClient`: RepositoryService service = factory.createClient(RepositoryService.class); ---- -or using `RestTemplate`: +using `RestTemplate`: [source,java,indent=0,subs="verbatim,quotes"] ---- RestTemplate restTemplate = new RestTemplate(); restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory("https://api.github.com/")); - RestTemplateAdapter adapter = RestTemplateAdapter.forTemplate(restTemplate); + RestTemplateAdapter adapter = RestTemplateAdapter.create(restTemplate); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + + RepositoryService service = factory.createClient(RepositoryService.class); +---- + +or using `RestClient`: + +[source,java,indent=0,subs="verbatim,quotes"] +---- + RestClient restClient = RestClient.builder().baseUrl("https://api.github.com/").build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); RepositoryService service = factory.createClient(RepositoryService.class); 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 new file mode 100644 index 000000000000..c6fd063d78bd --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -0,0 +1,128 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * {@link HttpExchangeAdapter} that enables an {@link HttpServiceProxyFactory} to use + * {@link RestClient} for request execution. + * + *

+ * Use static factory methods in this class to create an {@link HttpServiceProxyFactory} + * configured with a given {@link RestClient}. + * + * @author Olga Maciaszek-Sharma + * @since 6.1 + */ +public final class RestClientAdapter implements HttpExchangeAdapter { + + private final RestClient restClient; + + private RestClientAdapter(RestClient restClient) { + this.restClient = restClient; + } + + @Override + public boolean supportsRequestAttributes() { + return true; + } + + @Override + public void exchange(HttpRequestValues requestValues) { + newRequest(requestValues).retrieve().toBodilessEntity(); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + return newRequest(requestValues).retrieve().toBodilessEntity().getHeaders(); + } + + @Override + public T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return newRequest(requestValues).retrieve().body(bodyType); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + return newRequest(requestValues).retrieve().toBodilessEntity(); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + return newRequest(requestValues).retrieve().toEntity(bodyType); + } + + private RestClient.RequestBodySpec newRequest(HttpRequestValues requestValues) { + + HttpMethod httpMethod = requestValues.getHttpMethod(); + Assert.notNull(httpMethod, "HttpMethod is required"); + + RestClient.RequestBodyUriSpec uriSpec = this.restClient.method(httpMethod); + + RestClient.RequestBodySpec bodySpec; + if (requestValues.getUri() != null) { + bodySpec = uriSpec.uri(requestValues.getUri()); + } + else if (requestValues.getUriTemplate() != null) { + bodySpec = uriSpec.uri(requestValues.getUriTemplate(), requestValues.getUriVariables()); + } + else { + throw new IllegalStateException("Neither full URL nor URI template"); + } + + bodySpec.headers(headers -> headers.putAll(requestValues.getHeaders())); + + if (!requestValues.getCookies().isEmpty()) { + List cookies = new ArrayList<>(); + requestValues.getCookies().forEach((name, values) -> values.forEach(value -> { + HttpCookie cookie = new HttpCookie(name, value); + cookies.add(cookie.toString()); + })); + bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); + } + + bodySpec.attributes(attributes -> attributes.putAll(requestValues.getAttributes())); + + if (requestValues.getBodyValue() != null) { + bodySpec.body(requestValues.getBodyValue()); + } + + return bodySpec; + } + + /** + * Create a {@link RestClientAdapter} with the given {@link RestClient}. + */ + public static RestClientAdapter create(RestClient restClient) { + return new RestClientAdapter(restClient); + } + +} 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 da1e67622f3d..e78a7e5f305f 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 @@ -55,6 +55,7 @@ * @since 6.0 * @see org.springframework.web.client.support.RestTemplateAdapter * @see org.springframework.web.reactive.function.client.support.WebClientAdapter + * @see org.springframework.web.client.support.RestClientAdapter */ public final class HttpServiceProxyFactory { 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 new file mode 100644 index 000000000000..b5cffaaa3012 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -0,0 +1,211 @@ +/* + * 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.RestClient; +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link HttpServiceProxyFactory HTTP Service proxy} with + * {@link RestClientAdapter} connecting to {@link MockWebServer}. + * + * @author Olga Maciaszek-Sharma + */ +class RestClientAdapterTests { + + private MockWebServer server; + + private Service service; + + @BeforeEach + void setUp() { + this.server = new MockWebServer(); + prepareResponse(); + + RestClient restClient = RestClient.builder().baseUrl(this.server.url("/").toString()).build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + this.service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); + } + + @SuppressWarnings("ConstantConditions") + @AfterEach + void shutDown() throws IOException { + if (this.server != null) { + this.server.shutdown(); + } + } + + @Test + void greeting() throws InterruptedException { + String response = this.service.getGreeting(); + + RecordedRequest request = this.server.takeRequest(); + assertThat(response).isEqualTo("Hello Spring!"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/greeting"); + } + + @Test + void greetingById() throws InterruptedException { + ResponseEntity response = this.service.getGreetingById("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("/greeting/456"); + } + + @Test + void greetingWithDynamicUri() throws InterruptedException { + URI dynamicUri = this.server.url("/greeting/123").uri(); + + Optional response = this.service.getGreetingWithDynamicUri(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 postWithHeader() throws InterruptedException { + service.postWithHeader("testHeader", "testBody"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/greeting"); + 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"); + + service.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()); + + service.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 putWithCookies() throws InterruptedException { + service.putWithCookies("test1", "test2"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("PUT"); + assertThat(request.getHeader("Cookie")).isEqualTo("firstCookie=test1; secondCookie=test2"); + } + + @Test + void putWithSameNameCookies() throws InterruptedException { + service.putWithSameNameCookies("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 Service { + + @GetExchange("/greeting") + String getGreeting(); + + @GetExchange("/greeting/{id}") + ResponseEntity getGreetingById(@PathVariable String id); + + @GetExchange("/greeting/{id}") + Optional getGreetingWithDynamicUri(@Nullable URI uri, @PathVariable String id); + + @PostExchange("/greeting") + void postWithHeader(@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 putWithCookies(@CookieValue String firstCookie, @CookieValue String secondCookie); + + @PutExchange + void putWithSameNameCookies(@CookieValue("testCookie") String firstCookie, + @CookieValue("testCookie") String secondCookie); + + } + +}