From 35c8b855baf5f0da1e222d722dbc06a4b9eaacfe Mon Sep 17 00:00:00 2001 From: Devendra Reddy Pennabadi Date: Mon, 27 Apr 2026 19:59:17 +0000 Subject: [PATCH] Support HTTP Interface clients in test framework Signed-off-by: Devendra Reddy Pennabadi --- .../ROOT/pages/integration/rest-clients.adoc | 20 +++ .../server/support/WebTestClientAdapter.java | 142 ++++++++++++++++ .../reactive/server/support/package-info.java | 8 + .../client/support/RestTestClientAdapter.java | 152 ++++++++++++++++++ .../servlet/client/support/package-info.java | 8 + .../support/WebTestClientAdapterTests.java | 134 +++++++++++++++ .../support/RestTestClientAdapterTests.java | 134 +++++++++++++++ 7 files changed, 598 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapter.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/support/package-info.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapter.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/support/package-info.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapterTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapterTests.java diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 327aa7076f06..c1671d0a07f2 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -1029,6 +1029,26 @@ to a client application, what input and output types to create, what endpoint me signatures are needed, what Javadoc to have, and so on. The resulting Java API guides and is ready to use. +For testing, an HTTP Interface Client can also be backed by `WebTestClient` or +`RestTestClient` so that an `@HttpExchange` interface can be invoked against a mock +server (for example a controller bound via `WebTestClient.bindToController(...)` or +`RestTestClient.bindToController(...)`): + +[source,java,indent=0,subs="verbatim,quotes"] +---- + // Using RestTestClient... + + RestTestClient client = RestTestClient.bindToController(new MyController()).build(); + RestTestClientAdapter adapter = RestTestClientAdapter.create(client); + + // or WebTestClient... + + WebTestClient client = WebTestClient.bindToController(new MyController()).build(); + WebTestClientAdapter adapter = WebTestClientAdapter.create(client); + + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); +---- + [[rest-http-service-client-method-parameters]] === Method Parameters diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapter.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapter.java new file mode 100644 index 000000000000..233a26fbd990 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapter.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-present 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.test.web.reactive.server.support; + +import java.net.URI; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.ExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.Assert; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.util.UriBuilderFactory; + +/** + * {@link HttpExchangeAdapter} that enables an {@link HttpServiceProxyFactory} + * to use {@link WebTestClient} for request execution. + * + *

Use static factory methods in this class to create an + * {@link HttpServiceProxyFactory} configured with the given {@link WebTestClient}. + * + * @author Devendra Reddy Pennabadi + * @since 7.0 + */ +public final class WebTestClientAdapter implements HttpExchangeAdapter { + + private final WebTestClient webTestClient; + + + private WebTestClientAdapter(WebTestClient webTestClient) { + this.webTestClient = webTestClient; + } + + + @Override + public boolean supportsRequestAttributes() { + return true; + } + + @Override + public void exchange(HttpRequestValues values) { + newRequest(values).exchange().returnResult(Void.class); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return newRequest(values).exchange().returnResult(Void.class).getResponseHeaders(); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return newRequest(values).exchange().expectBody(bodyType).returnResult().getResponseBody(); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + ExchangeResult result = newRequest(values).exchange().returnResult(Void.class); + return ResponseEntity.status(result.getStatus()) + .headers(result.getResponseHeaders()) + .build(); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + EntityExchangeResult result = newRequest(values).exchange().expectBody(bodyType).returnResult(); + return ResponseEntity.status(result.getStatus()) + .headers(result.getResponseHeaders()) + .body(result.getResponseBody()); + } + + private WebTestClient.RequestBodySpec newRequest(HttpRequestValues values) { + HttpMethod method = values.getHttpMethod(); + Assert.notNull(method, "HttpMethod is required"); + + WebTestClient.RequestBodyUriSpec uriSpec = this.webTestClient.method(method); + WebTestClient.RequestBodySpec spec = setUri(uriSpec, values); + + spec.headers(headers -> headers.putAll(values.getHeaders())); + spec.cookies(cookies -> cookies.putAll(values.getCookies())); + if (values.getApiVersion() != null) { + spec.apiVersion(values.getApiVersion()); + } + spec.attributes(attributes -> attributes.putAll(values.getAttributes())); + setBody(spec, values); + return spec; + } + + private static WebTestClient.RequestBodySpec setUri( + WebTestClient.RequestBodyUriSpec spec, HttpRequestValues values) { + + if (values.getUri() != null) { + return spec.uri(values.getUri()); + } + if (values.getUriTemplate() != null) { + UriBuilderFactory uriBuilderFactory = values.getUriBuilderFactory(); + if (uriBuilderFactory != null) { + URI uri = uriBuilderFactory.expand(values.getUriTemplate(), values.getUriVariables()); + return spec.uri(uri); + } + return spec.uri(values.getUriTemplate(), values.getUriVariables()); + } + throw new IllegalStateException("Neither full URL nor URI template"); + } + + private static void setBody(WebTestClient.RequestBodySpec spec, HttpRequestValues values) { + if (values.getBodyValue() != null) { + spec.bodyValue(values.getBodyValue()); + } + } + + + /** + * Create a {@link WebTestClientAdapter} for the given {@link WebTestClient}. + * @param webTestClient the test client to use + * @return the created adapter instance + */ + public static WebTestClientAdapter create(WebTestClient webTestClient) { + return new WebTestClientAdapter(webTestClient); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/support/package-info.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/support/package-info.java new file mode 100644 index 000000000000..3e58f10b615d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/support/package-info.java @@ -0,0 +1,8 @@ +/** + * Classes supporting the {@code org.springframework.test.web.reactive.server} package. + * Contains an {@code HttpExchangeAdapter} backed by {@code WebTestClient}. + */ +@NullMarked +package org.springframework.test.web.reactive.server.support; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapter.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapter.java new file mode 100644 index 000000000000..408ef71d5ada --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapter.java @@ -0,0 +1,152 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.support; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; + +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.test.web.servlet.client.EntityExchangeResult; +import org.springframework.test.web.servlet.client.ExchangeResult; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.util.Assert; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.util.UriBuilderFactory; + +/** + * {@link HttpExchangeAdapter} that enables an {@link HttpServiceProxyFactory} + * to use {@link RestTestClient} for request execution. + * + *

Use static factory methods in this class to create an + * {@link HttpServiceProxyFactory} configured with the given {@link RestTestClient}. + * + * @author Devendra Reddy Pennabadi + * @since 7.0 + */ +public final class RestTestClientAdapter implements HttpExchangeAdapter { + + private final RestTestClient restTestClient; + + + private RestTestClientAdapter(RestTestClient restTestClient) { + this.restTestClient = restTestClient; + } + + + @Override + public boolean supportsRequestAttributes() { + return true; + } + + @Override + public void exchange(HttpRequestValues values) { + newRequest(values).exchange().returnResult(); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return newRequest(values).exchange().returnResult().getResponseHeaders(); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return newRequest(values).exchange().expectBody(bodyType).returnResult().getResponseBody(); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + ExchangeResult result = newRequest(values).exchange().returnResult(); + return ResponseEntity.status(result.getStatus()) + .headers(result.getResponseHeaders()) + .build(); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + EntityExchangeResult result = newRequest(values).exchange().expectBody(bodyType).returnResult(); + return ResponseEntity.status(result.getStatus()) + .headers(result.getResponseHeaders()) + .body(result.getResponseBody()); + } + + private RestTestClient.RequestBodySpec newRequest(HttpRequestValues values) { + HttpMethod method = values.getHttpMethod(); + Assert.notNull(method, "HttpMethod is required"); + + RestTestClient.RequestBodyUriSpec uriSpec = this.restTestClient.method(method); + RestTestClient.RequestBodySpec spec = setUri(uriSpec, values); + + spec.headers(headers -> headers.putAll(values.getHeaders())); + setCookieHeader(spec, values); + if (values.getApiVersion() != null) { + spec.apiVersion(values.getApiVersion()); + } + spec.attributes(attributes -> attributes.putAll(values.getAttributes())); + if (values.getBodyValue() != null) { + spec.body(values.getBodyValue()); + } + return spec; + } + + private static RestTestClient.RequestBodySpec setUri( + RestTestClient.RequestBodyUriSpec spec, HttpRequestValues values) { + + if (values.getUri() != null) { + return spec.uri(values.getUri()); + } + if (values.getUriTemplate() != null) { + UriBuilderFactory uriBuilderFactory = values.getUriBuilderFactory(); + if (uriBuilderFactory != null) { + URI uri = uriBuilderFactory.expand(values.getUriTemplate(), values.getUriVariables()); + return spec.uri(uri); + } + return spec.uri(values.getUriTemplate(), values.getUriVariables()); + } + throw new IllegalStateException("Neither full URL nor URI template"); + } + + private static void setCookieHeader(RestTestClient.RequestBodySpec spec, HttpRequestValues values) { + if (!values.getCookies().isEmpty()) { + List cookies = new ArrayList<>(); + values.getCookies().forEach((name, cookieValues) -> cookieValues.forEach(value -> { + HttpCookie cookie = new HttpCookie(name, value); + cookies.add(cookie.toString()); + })); + spec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); + } + } + + + /** + * Create a {@link RestTestClientAdapter} for the given {@link RestTestClient}. + * @param restTestClient the test client to use + * @return the created adapter instance + */ + public static RestTestClientAdapter create(RestTestClient restTestClient) { + return new RestTestClientAdapter(restTestClient); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/support/package-info.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/support/package-info.java new file mode 100644 index 000000000000..3c4263b2cc2c --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/support/package-info.java @@ -0,0 +1,8 @@ +/** + * Classes supporting the {@code org.springframework.test.web.servlet.client} package. + * Contains an {@code HttpExchangeAdapter} backed by {@code RestTestClient}. + */ +@NullMarked +package org.springframework.test.web.servlet.client.support; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapterTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapterTests.java new file mode 100644 index 000000000000..ee59b666554d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/support/WebTestClientAdapterTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2002-present 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.test.web.reactive.server.support; + +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.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link WebTestClientAdapter}. + * + * @author Devendra Reddy Pennabadi + */ +class WebTestClientAdapterTests { + + private TestService service; + + + @BeforeEach + void setUp() { + WebTestClient client = WebTestClient.bindToController(new TestController()).build(); + WebTestClientAdapter adapter = WebTestClientAdapter.create(client); + this.service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(TestService.class); + } + + + @Test + void getAsString() { + String result = this.service.getGreeting(); + assertThat(result).isEqualTo("Hello Spring!"); + } + + @Test + void getAsResponseEntity() { + ResponseEntity entity = this.service.getGreetingEntity(); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).isEqualTo("Hello Spring!"); + } + + @Test + void getWithPathVariable() { + String result = this.service.getGreetingById("42"); + assertThat(result).isEqualTo("Hello 42"); + } + + @Test + void postBody() { + String result = this.service.echo(new Greeting("Spring")); + assertThat(result).isEqualTo("echo: Spring"); + } + + @Test + void requestHeader() { + String result = this.service.getWithHeader("custom-value"); + assertThat(result).isEqualTo("header: custom-value"); + } + + + private interface TestService { + + @GetExchange("/greeting") + String getGreeting(); + + @GetExchange("/greeting") + ResponseEntity getGreetingEntity(); + + @GetExchange("/greeting/{id}") + String getGreetingById(@PathVariable String id); + + @PostExchange("/echo") + String echo(@RequestBody Greeting body); + + @GetExchange("/with-header") + String getWithHeader(@RequestHeader("X-Custom") String value); + } + + + @RestController + static class TestController { + + @GetMapping("/greeting") + String greeting() { + return "Hello Spring!"; + } + + @GetMapping("/greeting/{id}") + String greetingById(@PathVariable String id) { + return "Hello " + id; + } + + @PostMapping("/echo") + String echo(@RequestBody Greeting body) { + return "echo: " + body.name(); + } + + @GetMapping("/with-header") + String withHeader(@RequestHeader("X-Custom") String value) { + return "header: " + value; + } + } + + + record Greeting(String name) { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapterTests.java new file mode 100644 index 000000000000..14d64bd6ed24 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/support/RestTestClientAdapterTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.support; + +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.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link RestTestClientAdapter}. + * + * @author Devendra Reddy Pennabadi + */ +class RestTestClientAdapterTests { + + private TestService service; + + + @BeforeEach + void setUp() { + RestTestClient client = RestTestClient.bindToController(new TestController()).build(); + RestTestClientAdapter adapter = RestTestClientAdapter.create(client); + this.service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(TestService.class); + } + + + @Test + void getAsString() { + String result = this.service.getGreeting(); + assertThat(result).isEqualTo("Hello Spring!"); + } + + @Test + void getAsResponseEntity() { + ResponseEntity entity = this.service.getGreetingEntity(); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).isEqualTo("Hello Spring!"); + } + + @Test + void getWithPathVariable() { + String result = this.service.getGreetingById("42"); + assertThat(result).isEqualTo("Hello 42"); + } + + @Test + void postBody() { + String result = this.service.echo(new Greeting("Spring")); + assertThat(result).isEqualTo("echo: Spring"); + } + + @Test + void requestHeader() { + String result = this.service.getWithHeader("custom-value"); + assertThat(result).isEqualTo("header: custom-value"); + } + + + private interface TestService { + + @GetExchange("/greeting") + String getGreeting(); + + @GetExchange("/greeting") + ResponseEntity getGreetingEntity(); + + @GetExchange("/greeting/{id}") + String getGreetingById(@PathVariable String id); + + @PostExchange("/echo") + String echo(@RequestBody Greeting body); + + @GetExchange("/with-header") + String getWithHeader(@RequestHeader("X-Custom") String value); + } + + + @RestController + static class TestController { + + @GetMapping("/greeting") + String greeting() { + return "Hello Spring!"; + } + + @GetMapping("/greeting/{id}") + String greetingById(@PathVariable String id) { + return "Hello " + id; + } + + @PostMapping("/echo") + String echo(@RequestBody Greeting body) { + return "echo: " + body.name(); + } + + @GetMapping("/with-header") + String withHeader(@RequestHeader("X-Custom") String value) { + return "header: " + value; + } + } + + + record Greeting(String name) { + } + +}