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);
+
+ }
+
+}