Skip to content

Commit 8b77ed1

Browse files
OlgaMaciaszekrstoyanchev
authored andcommitted
Add RestClientAdapter
See gh-30869
1 parent 3a8c40f commit 8b77ed1

File tree

4 files changed

+353
-2
lines changed

4 files changed

+353
-2
lines changed

framework-docs/modules/ROOT/pages/integration/rest-clients.adoc

+13-2
Original file line numberDiff line numberDiff line change
@@ -394,13 +394,24 @@ either using `WebClient`:
394394
RepositoryService service = factory.createClient(RepositoryService.class);
395395
----
396396

397-
or using `RestTemplate`:
397+
using `RestTemplate`:
398398

399399
[source,java,indent=0,subs="verbatim,quotes"]
400400
----
401401
RestTemplate restTemplate = new RestTemplate();
402402
restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory("https://api.github.com/"));
403-
RestTemplateAdapter adapter = RestTemplateAdapter.forTemplate(restTemplate);
403+
RestTemplateAdapter adapter = RestTemplateAdapter.create(restTemplate);
404+
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
405+
406+
RepositoryService service = factory.createClient(RepositoryService.class);
407+
----
408+
409+
or using `RestClient`:
410+
411+
[source,java,indent=0,subs="verbatim,quotes"]
412+
----
413+
RestClient restClient = RestClient.builder().baseUrl("https://api.github.com/").build();
414+
RestClientAdapter adapter = RestClientAdapter.create(restClient);
404415
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
405416
406417
RepositoryService service = factory.createClient(RepositoryService.class);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.client.support;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
22+
import org.springframework.core.ParameterizedTypeReference;
23+
import org.springframework.http.HttpCookie;
24+
import org.springframework.http.HttpHeaders;
25+
import org.springframework.http.HttpMethod;
26+
import org.springframework.http.ResponseEntity;
27+
import org.springframework.util.Assert;
28+
import org.springframework.web.client.RestClient;
29+
import org.springframework.web.service.invoker.HttpExchangeAdapter;
30+
import org.springframework.web.service.invoker.HttpRequestValues;
31+
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
32+
33+
/**
34+
* {@link HttpExchangeAdapter} that enables an {@link HttpServiceProxyFactory} to use
35+
* {@link RestClient} for request execution.
36+
*
37+
* <p>
38+
* Use static factory methods in this class to create an {@link HttpServiceProxyFactory}
39+
* configured with a given {@link RestClient}.
40+
*
41+
* @author Olga Maciaszek-Sharma
42+
* @since 6.1
43+
*/
44+
public final class RestClientAdapter implements HttpExchangeAdapter {
45+
46+
private final RestClient restClient;
47+
48+
private RestClientAdapter(RestClient restClient) {
49+
this.restClient = restClient;
50+
}
51+
52+
@Override
53+
public boolean supportsRequestAttributes() {
54+
return true;
55+
}
56+
57+
@Override
58+
public void exchange(HttpRequestValues requestValues) {
59+
newRequest(requestValues).retrieve().toBodilessEntity();
60+
}
61+
62+
@Override
63+
public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) {
64+
return newRequest(requestValues).retrieve().toBodilessEntity().getHeaders();
65+
}
66+
67+
@Override
68+
public <T> T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference<T> bodyType) {
69+
return newRequest(requestValues).retrieve().body(bodyType);
70+
}
71+
72+
@Override
73+
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues requestValues) {
74+
return newRequest(requestValues).retrieve().toBodilessEntity();
75+
}
76+
77+
@Override
78+
public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues requestValues,
79+
ParameterizedTypeReference<T> bodyType) {
80+
return newRequest(requestValues).retrieve().toEntity(bodyType);
81+
}
82+
83+
private RestClient.RequestBodySpec newRequest(HttpRequestValues requestValues) {
84+
85+
HttpMethod httpMethod = requestValues.getHttpMethod();
86+
Assert.notNull(httpMethod, "HttpMethod is required");
87+
88+
RestClient.RequestBodyUriSpec uriSpec = this.restClient.method(httpMethod);
89+
90+
RestClient.RequestBodySpec bodySpec;
91+
if (requestValues.getUri() != null) {
92+
bodySpec = uriSpec.uri(requestValues.getUri());
93+
}
94+
else if (requestValues.getUriTemplate() != null) {
95+
bodySpec = uriSpec.uri(requestValues.getUriTemplate(), requestValues.getUriVariables());
96+
}
97+
else {
98+
throw new IllegalStateException("Neither full URL nor URI template");
99+
}
100+
101+
bodySpec.headers(headers -> headers.putAll(requestValues.getHeaders()));
102+
103+
if (!requestValues.getCookies().isEmpty()) {
104+
List<String> cookies = new ArrayList<>();
105+
requestValues.getCookies().forEach((name, values) -> values.forEach(value -> {
106+
HttpCookie cookie = new HttpCookie(name, value);
107+
cookies.add(cookie.toString());
108+
}));
109+
bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies));
110+
}
111+
112+
bodySpec.attributes(attributes -> attributes.putAll(requestValues.getAttributes()));
113+
114+
if (requestValues.getBodyValue() != null) {
115+
bodySpec.body(requestValues.getBodyValue());
116+
}
117+
118+
return bodySpec;
119+
}
120+
121+
/**
122+
* Create a {@link RestClientAdapter} with the given {@link RestClient}.
123+
*/
124+
public static RestClientAdapter create(RestClient restClient) {
125+
return new RestClientAdapter(restClient);
126+
}
127+
128+
}

spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
* @since 6.0
5656
* @see org.springframework.web.client.support.RestTemplateAdapter
5757
* @see org.springframework.web.reactive.function.client.support.WebClientAdapter
58+
* @see org.springframework.web.client.support.RestClientAdapter
5859
*/
5960
public final class HttpServiceProxyFactory {
6061

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.client.support;
18+
19+
import java.io.IOException;
20+
import java.net.URI;
21+
import java.util.Optional;
22+
23+
import okhttp3.mockwebserver.MockResponse;
24+
import okhttp3.mockwebserver.MockWebServer;
25+
import okhttp3.mockwebserver.RecordedRequest;
26+
import org.junit.jupiter.api.AfterEach;
27+
import org.junit.jupiter.api.BeforeEach;
28+
import org.junit.jupiter.api.Test;
29+
30+
import org.springframework.http.HttpStatus;
31+
import org.springframework.http.MediaType;
32+
import org.springframework.http.ResponseEntity;
33+
import org.springframework.lang.Nullable;
34+
import org.springframework.util.LinkedMultiValueMap;
35+
import org.springframework.util.MultiValueMap;
36+
import org.springframework.web.bind.annotation.CookieValue;
37+
import org.springframework.web.bind.annotation.PathVariable;
38+
import org.springframework.web.bind.annotation.RequestBody;
39+
import org.springframework.web.bind.annotation.RequestHeader;
40+
import org.springframework.web.bind.annotation.RequestParam;
41+
import org.springframework.web.bind.annotation.RequestPart;
42+
import org.springframework.web.client.RestClient;
43+
import org.springframework.web.multipart.MultipartFile;
44+
import org.springframework.web.service.annotation.GetExchange;
45+
import org.springframework.web.service.annotation.PostExchange;
46+
import org.springframework.web.service.annotation.PutExchange;
47+
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
48+
import org.springframework.web.testfixture.servlet.MockMultipartFile;
49+
50+
import static org.assertj.core.api.Assertions.assertThat;
51+
52+
/**
53+
* Integration tests for {@link HttpServiceProxyFactory HTTP Service proxy} with
54+
* {@link RestClientAdapter} connecting to {@link MockWebServer}.
55+
*
56+
* @author Olga Maciaszek-Sharma
57+
*/
58+
class RestClientAdapterTests {
59+
60+
private MockWebServer server;
61+
62+
private Service service;
63+
64+
@BeforeEach
65+
void setUp() {
66+
this.server = new MockWebServer();
67+
prepareResponse();
68+
69+
RestClient restClient = RestClient.builder().baseUrl(this.server.url("/").toString()).build();
70+
RestClientAdapter adapter = RestClientAdapter.create(restClient);
71+
this.service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class);
72+
}
73+
74+
@SuppressWarnings("ConstantConditions")
75+
@AfterEach
76+
void shutDown() throws IOException {
77+
if (this.server != null) {
78+
this.server.shutdown();
79+
}
80+
}
81+
82+
@Test
83+
void greeting() throws InterruptedException {
84+
String response = this.service.getGreeting();
85+
86+
RecordedRequest request = this.server.takeRequest();
87+
assertThat(response).isEqualTo("Hello Spring!");
88+
assertThat(request.getMethod()).isEqualTo("GET");
89+
assertThat(request.getPath()).isEqualTo("/greeting");
90+
}
91+
92+
@Test
93+
void greetingById() throws InterruptedException {
94+
ResponseEntity<String> response = this.service.getGreetingById("456");
95+
96+
RecordedRequest request = this.server.takeRequest();
97+
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
98+
assertThat(response.getBody()).isEqualTo("Hello Spring!");
99+
assertThat(request.getMethod()).isEqualTo("GET");
100+
assertThat(request.getPath()).isEqualTo("/greeting/456");
101+
}
102+
103+
@Test
104+
void greetingWithDynamicUri() throws InterruptedException {
105+
URI dynamicUri = this.server.url("/greeting/123").uri();
106+
107+
Optional<String> response = this.service.getGreetingWithDynamicUri(dynamicUri, "456");
108+
109+
RecordedRequest request = this.server.takeRequest();
110+
assertThat(response.orElse("empty")).isEqualTo("Hello Spring!");
111+
assertThat(request.getMethod()).isEqualTo("GET");
112+
assertThat(request.getRequestUrl().uri()).isEqualTo(dynamicUri);
113+
}
114+
115+
@Test
116+
void postWithHeader() throws InterruptedException {
117+
service.postWithHeader("testHeader", "testBody");
118+
119+
RecordedRequest request = this.server.takeRequest();
120+
assertThat(request.getMethod()).isEqualTo("POST");
121+
assertThat(request.getPath()).isEqualTo("/greeting");
122+
assertThat(request.getHeaders().get("testHeaderName")).isEqualTo("testHeader");
123+
assertThat(request.getBody().readUtf8()).isEqualTo("testBody");
124+
}
125+
126+
@Test
127+
void formData() throws Exception {
128+
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
129+
map.add("param1", "value 1");
130+
map.add("param2", "value 2");
131+
132+
service.postForm(map);
133+
134+
RecordedRequest request = this.server.takeRequest();
135+
assertThat(request.getHeaders().get("Content-Type"))
136+
.isEqualTo("application/x-www-form-urlencoded;charset=UTF-8");
137+
assertThat(request.getBody().readUtf8()).isEqualTo("param1=value+1&param2=value+2");
138+
}
139+
140+
@Test // gh-30342
141+
void multipart() throws InterruptedException {
142+
String fileName = "testFileName";
143+
String originalFileName = "originalTestFileName";
144+
MultipartFile file = new MockMultipartFile(fileName, originalFileName, MediaType.APPLICATION_JSON_VALUE,
145+
"test".getBytes());
146+
147+
service.postMultipart(file, "test2");
148+
149+
RecordedRequest request = this.server.takeRequest();
150+
assertThat(request.getHeaders().get("Content-Type")).startsWith("multipart/form-data;boundary=");
151+
assertThat(request.getBody().readUtf8()).containsSubsequence(
152+
"Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"",
153+
"Content-Type: application/json", "Content-Length: 4", "test",
154+
"Content-Disposition: form-data; name=\"anotherPart\"", "Content-Type: text/plain;charset=UTF-8",
155+
"Content-Length: 5", "test2");
156+
}
157+
158+
@Test
159+
void putWithCookies() throws InterruptedException {
160+
service.putWithCookies("test1", "test2");
161+
162+
RecordedRequest request = this.server.takeRequest();
163+
assertThat(request.getMethod()).isEqualTo("PUT");
164+
assertThat(request.getHeader("Cookie")).isEqualTo("firstCookie=test1; secondCookie=test2");
165+
}
166+
167+
@Test
168+
void putWithSameNameCookies() throws InterruptedException {
169+
service.putWithSameNameCookies("test1", "test2");
170+
171+
RecordedRequest request = this.server.takeRequest();
172+
assertThat(request.getMethod()).isEqualTo("PUT");
173+
assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test1; testCookie=test2");
174+
}
175+
176+
private void prepareResponse() {
177+
MockResponse response = new MockResponse();
178+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!");
179+
this.server.enqueue(response);
180+
}
181+
182+
private interface Service {
183+
184+
@GetExchange("/greeting")
185+
String getGreeting();
186+
187+
@GetExchange("/greeting/{id}")
188+
ResponseEntity<String> getGreetingById(@PathVariable String id);
189+
190+
@GetExchange("/greeting/{id}")
191+
Optional<String> getGreetingWithDynamicUri(@Nullable URI uri, @PathVariable String id);
192+
193+
@PostExchange("/greeting")
194+
void postWithHeader(@RequestHeader("testHeaderName") String testHeader, @RequestBody String requestBody);
195+
196+
@PostExchange(contentType = "application/x-www-form-urlencoded")
197+
void postForm(@RequestParam MultiValueMap<String, String> params);
198+
199+
@PostExchange
200+
void postMultipart(MultipartFile file, @RequestPart String anotherPart);
201+
202+
@PutExchange
203+
void putWithCookies(@CookieValue String firstCookie, @CookieValue String secondCookie);
204+
205+
@PutExchange
206+
void putWithSameNameCookies(@CookieValue("testCookie") String firstCookie,
207+
@CookieValue("testCookie") String secondCookie);
208+
209+
}
210+
211+
}

0 commit comments

Comments
 (0)