Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HttpExchangeAdapter for RestClient #30869

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions framework-docs/modules/ROOT/pages/integration/rest-clients.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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> T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference<T> bodyType) {
return newRequest(requestValues).retrieve().body(bodyType);
}

@Override
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues requestValues) {
return newRequest(requestValues).retrieve().toBodilessEntity();
}

@Override
public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues requestValues,
ParameterizedTypeReference<T> 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<String> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String, String> 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&param2=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<String> getGreetingById(@PathVariable String id);

@GetExchange("/greeting/{id}")
Optional<String> 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<String, String> 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);

}

}