From 1982c7e0200c81dcc5af985445434cc3f940cf4b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 4 Jul 2025 17:27:23 +0100 Subject: [PATCH] Support 404 handling for HttpExchange interfaces Closes gh-32105 --- .../ROOT/pages/integration/rest-clients.adoc | 28 +++++ .../NotFoundRestClientAdapterDecorator.java | 76 ++++++++++++ .../invoker/HttpServiceProxyFactory.java | 26 ++-- .../ReactorHttpExchangeAdapterDecorator.java | 2 +- .../support/RestClientAdapterTests.java | 59 +++++++-- .../NotFoundWebClientAdapterDecorator.java | 112 ++++++++++++++++++ .../client/support/WebClientAdapterTests.java | 31 +++++ 7 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/client/support/NotFoundRestClientAdapterDecorator.java create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/NotFoundWebClientAdapterDecorator.java diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 057cee0d2c2..31d78969e01 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -1141,6 +1141,34 @@ documentation for each client, as well as the Javadoc of `defaultStatusHandler` +[[rest-http-interface-adapter-decorator]] +=== Decorating the Adapter + +`HttpExchangeAdapter` and `ReactorHttpExchangeAdapter` are contracts that decouple HTTP +Interface client infrastructure from the details of invoking the underlying +client. There are adapter implementations for `RestClient`, `WebClient`, and +`RestTemplate`. + +Occasionally, it may be useful to intercept client invocations through a decorator +configurable in the `HttpServiceProxyFactory.Builder`. For example, you can apply +built-in decorators to suppress 404 exceptions and return a `ResponseEntity` with +`NOT_FOUND` and a `null` body: + +[source,java,indent=0,subs="verbatim,quotes"] +---- + // For RestClient + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(restCqlientAdapter) + .exchangeAdapterDecorator(NotFoundRestClientAdapterDecorator::new) + .build(); + + // or for WebClient... + HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builderFor(webClientAdapter) + .exchangeAdapterDecorator(NotFoundWebClientAdapterDecorator::new) + .build(); +---- + + + [[rest-http-interface-group-config]] === HTTP Interface Groups diff --git a/spring-web/src/main/java/org/springframework/web/client/support/NotFoundRestClientAdapterDecorator.java b/spring-web/src/main/java/org/springframework/web/client/support/NotFoundRestClientAdapterDecorator.java new file mode 100644 index 00000000000..3c01b73300f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/support/NotFoundRestClientAdapterDecorator.java @@ -0,0 +1,76 @@ +/* + * 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.web.client.support; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * {@code HttpExchangeAdapterDecorator} that suppresses the + * {@link HttpClientErrorException.NotFound} exception raised on a 404 response + * and returns a {@code ResponseEntity} with the status set to + * {@link org.springframework.http.HttpStatus#NOT_FOUND} status, or + * {@code null} from {@link #exchangeForBody}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public final class NotFoundRestClientAdapterDecorator extends HttpExchangeAdapterDecorator { + + + public NotFoundRestClientAdapterDecorator(HttpExchangeAdapter delegate) { + super(delegate); + } + + + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + try { + return super.exchangeForBody(values, bodyType); + } + catch (HttpClientErrorException.NotFound ex) { + return null; + } + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + try { + return super.exchangeForBodilessEntity(values); + } + catch (HttpClientErrorException.NotFound ex) { + return ResponseEntity.notFound().build(); + } + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + try { + return super.exchangeForEntity(values, bodyType); + } + catch (HttpClientErrorException.NotFound ex) { + return ResponseEntity.notFound().build(); + } + } + +} 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 30b52d906db..220288778a9 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 @@ -140,10 +140,10 @@ public final class HttpServiceProxyFactory { private final List customArgumentResolvers = new ArrayList<>(); - private final List requestValuesProcessors = new ArrayList<>(); - private @Nullable ConversionService conversionService; + private final List requestValuesProcessors = new ArrayList<>(); + private @Nullable StringValueResolver embeddedValueResolver; private Builder() { @@ -182,25 +182,25 @@ public final class HttpServiceProxyFactory { } /** - * Register an {@link HttpRequestValues} processor that can further - * customize request values based on the method and all arguments. - * @param processor the processor to add + * Set the {@link ConversionService} to use where input values need to + * be formatted as Strings. + *

By default, this is {@link DefaultFormattingConversionService}. * @return this same builder instance - * @since 7.0 */ - public Builder httpRequestValuesProcessor(HttpRequestValues.Processor processor) { - this.requestValuesProcessors.add(processor); + public Builder conversionService(ConversionService conversionService) { + this.conversionService = conversionService; return this; } /** - * Set the {@link ConversionService} to use where input values need to - * be formatted as Strings. - *

By default this is {@link DefaultFormattingConversionService}. + * Register an {@link HttpRequestValues} processor that can further + * customize request values based on the method and all arguments. + * @param processor the processor to add * @return this same builder instance + * @since 7.0 */ - public Builder conversionService(ConversionService conversionService) { - this.conversionService = conversionService; + public Builder httpRequestValuesProcessor(HttpRequestValues.Processor processor) { + this.requestValuesProcessors.add(processor); return this; } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapterDecorator.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapterDecorator.java index 0559ae4cf0f..488ddb1c45b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapterDecorator.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapterDecorator.java @@ -43,7 +43,7 @@ public class ReactorHttpExchangeAdapterDecorator extends HttpExchangeAdapterDeco /** - * Return the wrapped delgate {@code HttpExchangeAdapter}. + * Return the wrapped delegate {@code HttpExchangeAdapter}. */ @Override public ReactorHttpExchangeAdapter getHttpExchangeAdapter() { 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 index 4f1d7fd6151..4c3dfdedf95 100644 --- 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 @@ -28,6 +28,7 @@ import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.stream.Stream; import io.micrometer.observation.tck.TestObservationRegistry; @@ -79,17 +80,15 @@ import static org.assertj.core.api.Assertions.assertThat; @SuppressWarnings("JUnitMalformedDeclaration") class RestClientAdapterTests { - private final MockWebServer anotherServer = anotherServer(); + private final MockWebServer anotherServer = new MockWebServer(); - @SuppressWarnings("ConstantValue") @AfterEach void shutdown() throws IOException { - if (this.anotherServer != null) { - this.anotherServer.shutdown(); - } + this.anotherServer.shutdown(); } + @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @ParameterizedTest @@ -173,6 +172,9 @@ class RestClientAdapterTests { @Test void greetingWithApiVersion() throws Exception { + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + RestClient restClient = RestClient.builder() .baseUrl(anotherServer.url("/").toString()) .apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version")) @@ -181,15 +183,18 @@ class RestClientAdapterTests { RestClientAdapter adapter = RestClientAdapter.create(restClient); Service service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); - String response = service.getGreetingWithVersion(); + String actualResponse = service.getGreetingWithVersion(); RecordedRequest request = anotherServer.takeRequest(); assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"); - assertThat(response).isEqualTo("Hello Spring 2!"); + assertThat(actualResponse).isEqualTo("Hello Spring 2!"); } @ParameterizedAdapterTest void getWithUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + String url = this.anotherServer.url("/").toString(); UriBuilderFactory factory = new DefaultUriBuilderFactory(url); @@ -205,6 +210,9 @@ class RestClientAdapterTests { @ParameterizedAdapterTest void getWithFactoryPathVariableAndRequestParam(MockWebServer server, Service service) throws InterruptedException { + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + String url = this.anotherServer.url("/").toString(); UriBuilderFactory factory = new DefaultUriBuilderFactory(url); @@ -220,6 +228,9 @@ class RestClientAdapterTests { @ParameterizedAdapterTest void getWithIgnoredUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + URI dynamicUri = server.url("/greeting/123").uri(); UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/").toString()); @@ -306,6 +317,9 @@ class RestClientAdapterTests { @Test void getInputStream() throws Exception { + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + InputStream inputStream = initService().getInputStream(); RecordedRequest request = this.anotherServer.takeRequest(); @@ -315,6 +329,9 @@ class RestClientAdapterTests { @Test void postOutputStream() throws Exception { + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + String body = "test stream"; initService().postOutputStream(outputStream -> outputStream.write(body.getBytes())); @@ -323,13 +340,23 @@ class RestClientAdapterTests { assertThat(request.getBody().readUtf8()).isEqualTo(body); } - - private static MockWebServer anotherServer() { - MockWebServer server = new MockWebServer(); + @Test + void handleNotFoundException() { MockResponse response = new MockResponse(); - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"); - server.enqueue(response); - return server; + response.setResponseCode(404); + this.anotherServer.enqueue(response); + + RestClientAdapter clientAdapter = RestClientAdapter.create( + RestClient.builder().baseUrl(this.anotherServer.url("/").toString()).build()); + + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(clientAdapter) + .exchangeAdapterDecorator(NotFoundRestClientAdapterDecorator::new) + .build(); + + ResponseEntity responseEntity = factory.createClient(Service.class).getGreetingById("1"); + + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(responseEntity.getBody()).isNull(); } private Service initService() { @@ -339,6 +366,12 @@ class RestClientAdapterTests { return HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); } + private void prepareResponse(Consumer consumer) { + MockResponse response = new MockResponse(); + consumer.accept(response); + this.anotherServer.enqueue(response); + } + private interface Service { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/NotFoundWebClientAdapterDecorator.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/NotFoundWebClientAdapterDecorator.java new file mode 100644 index 00000000000..f53035bdc8f --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/NotFoundWebClientAdapterDecorator.java @@ -0,0 +1,112 @@ +/* + * 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.web.reactive.function.client.support; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapterDecorator; + +/** + * {@code HttpExchangeAdapterDecorator} that suppresses the + * {@link WebClientResponseException.NotFound} exception resulting from 404 + * responses and returns a {@code ResponseEntity} with the status set to + * {@link org.springframework.http.HttpStatus#NOT_FOUND} status, or an empty + * {@code Mono} from {@link #exchangeForBodyMono}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class NotFoundWebClientAdapterDecorator extends ReactorHttpExchangeAdapterDecorator { + + + public NotFoundWebClientAdapterDecorator(HttpExchangeAdapter delegate) { + super(delegate); + } + + + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + try { + return super.exchangeForBody(values, bodyType); + } + catch (WebClientResponseException.NotFound ex) { + return null; + } + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + try { + return super.exchangeForBodilessEntity(values); + } + catch (WebClientResponseException.NotFound ex) { + return ResponseEntity.notFound().build(); + } + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + try { + return super.exchangeForEntity(values, bodyType); + } + catch (WebClientResponseException.NotFound ex) { + return ResponseEntity.notFound().build(); + } + } + + @Override + public Mono exchangeForBodyMono(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return super.exchangeForBodyMono(values, bodyType).onErrorResume( + WebClientResponseException.NotFound.class, ex -> Mono.empty()); + } + + @Override + public Flux exchangeForBodyFlux(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return super.exchangeForBodyFlux(values, bodyType).onErrorResume( + WebClientResponseException.NotFound.class, ex -> Flux.empty()); + } + + @Override + public Mono> exchangeForBodilessEntityMono(HttpRequestValues values) { + return super.exchangeForBodilessEntityMono(values).onErrorResume( + WebClientResponseException.NotFound.class, ex -> Mono.just(ResponseEntity.notFound().build())); + } + + @Override + public Mono> exchangeForEntityMono( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + + return super.exchangeForEntityMono(values, bodyType).onErrorResume( + WebClientResponseException.NotFound.class, ex -> Mono.just(ResponseEntity.notFound().build())); + } + + @Override + public Mono>> exchangeForEntityFlux( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + + return super.exchangeForEntityFlux(values, bodyType).onErrorResume( + WebClientResponseException.NotFound.class, ex -> Mono.just(ResponseEntity.notFound().build())); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java index b6d82b735f6..0f277fbfd8f 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java @@ -36,7 +36,9 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PathVariable; @@ -225,6 +227,32 @@ class WebClientAdapterTests { assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); } + @Test + void handleNotFoundException() { + MockResponse response = new MockResponse(); + response.setResponseCode(404); + this.server.enqueue(response); + this.server.enqueue(response); + + WebClientAdapter clientAdapter = WebClientAdapter.create( + WebClient.builder().baseUrl(this.server.url("/").toString()).build()); + + HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builderFor(clientAdapter) + .exchangeAdapterDecorator(NotFoundWebClientAdapterDecorator::new) + .build(); + + Service service = proxyFactory.createClient(Service.class); + + StepVerifier.create(service.getGreeting()).verifyComplete(); + + StepVerifier.create(service.getGreetingEntity()) + .consumeNextWith(entity -> { + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(entity.getBody()).isNull(); + }) + .verifyComplete(); + } + private static MockWebServer anotherServer() { MockWebServer anotherServer = new MockWebServer(); @@ -262,6 +290,9 @@ class WebClientAdapterTests { @GetExchange("/greetings/{id}") String getGreetingById(@Nullable URI uri, @PathVariable String id); + @GetExchange("/greeting") + Mono> getGreetingEntity(); + @PostExchange(contentType = "application/x-www-form-urlencoded") void postForm(@RequestParam MultiValueMap params);