Skip to content

Commit 1982c7e

Browse files
committed
Support 404 handling for HttpExchange interfaces
Closes gh-32105
1 parent 340468c commit 1982c7e

File tree

7 files changed

+307
-27
lines changed

7 files changed

+307
-27
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,34 @@ documentation for each client, as well as the Javadoc of `defaultStatusHandler`
11411141

11421142

11431143

1144+
[[rest-http-interface-adapter-decorator]]
1145+
=== Decorating the Adapter
1146+
1147+
`HttpExchangeAdapter` and `ReactorHttpExchangeAdapter` are contracts that decouple HTTP
1148+
Interface client infrastructure from the details of invoking the underlying
1149+
client. There are adapter implementations for `RestClient`, `WebClient`, and
1150+
`RestTemplate`.
1151+
1152+
Occasionally, it may be useful to intercept client invocations through a decorator
1153+
configurable in the `HttpServiceProxyFactory.Builder`. For example, you can apply
1154+
built-in decorators to suppress 404 exceptions and return a `ResponseEntity` with
1155+
`NOT_FOUND` and a `null` body:
1156+
1157+
[source,java,indent=0,subs="verbatim,quotes"]
1158+
----
1159+
// For RestClient
1160+
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(restCqlientAdapter)
1161+
.exchangeAdapterDecorator(NotFoundRestClientAdapterDecorator::new)
1162+
.build();
1163+
1164+
// or for WebClient...
1165+
HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builderFor(webClientAdapter)
1166+
.exchangeAdapterDecorator(NotFoundWebClientAdapterDecorator::new)
1167+
.build();
1168+
----
1169+
1170+
1171+
11441172
[[rest-http-interface-group-config]]
11451173
=== HTTP Interface Groups
11461174

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2002-present 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 org.jspecify.annotations.Nullable;
20+
21+
import org.springframework.core.ParameterizedTypeReference;
22+
import org.springframework.http.ResponseEntity;
23+
import org.springframework.web.client.HttpClientErrorException;
24+
import org.springframework.web.service.invoker.HttpExchangeAdapter;
25+
import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator;
26+
import org.springframework.web.service.invoker.HttpRequestValues;
27+
28+
/**
29+
* {@code HttpExchangeAdapterDecorator} that suppresses the
30+
* {@link HttpClientErrorException.NotFound} exception raised on a 404 response
31+
* and returns a {@code ResponseEntity} with the status set to
32+
* {@link org.springframework.http.HttpStatus#NOT_FOUND} status, or
33+
* {@code null} from {@link #exchangeForBody}.
34+
*
35+
* @author Rossen Stoyanchev
36+
* @since 7.0
37+
*/
38+
public final class NotFoundRestClientAdapterDecorator extends HttpExchangeAdapterDecorator {
39+
40+
41+
public NotFoundRestClientAdapterDecorator(HttpExchangeAdapter delegate) {
42+
super(delegate);
43+
}
44+
45+
46+
@Override
47+
public <T> @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
48+
try {
49+
return super.exchangeForBody(values, bodyType);
50+
}
51+
catch (HttpClientErrorException.NotFound ex) {
52+
return null;
53+
}
54+
}
55+
56+
@Override
57+
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
58+
try {
59+
return super.exchangeForBodilessEntity(values);
60+
}
61+
catch (HttpClientErrorException.NotFound ex) {
62+
return ResponseEntity.notFound().build();
63+
}
64+
}
65+
66+
@Override
67+
public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
68+
try {
69+
return super.exchangeForEntity(values, bodyType);
70+
}
71+
catch (HttpClientErrorException.NotFound ex) {
72+
return ResponseEntity.notFound().build();
73+
}
74+
}
75+
76+
}

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ public static final class Builder {
140140

141141
private final List<HttpServiceArgumentResolver> customArgumentResolvers = new ArrayList<>();
142142

143-
private final List<HttpRequestValues.Processor> requestValuesProcessors = new ArrayList<>();
144-
145143
private @Nullable ConversionService conversionService;
146144

145+
private final List<HttpRequestValues.Processor> requestValuesProcessors = new ArrayList<>();
146+
147147
private @Nullable StringValueResolver embeddedValueResolver;
148148

149149
private Builder() {
@@ -182,25 +182,25 @@ public Builder customArgumentResolver(HttpServiceArgumentResolver resolver) {
182182
}
183183

184184
/**
185-
* Register an {@link HttpRequestValues} processor that can further
186-
* customize request values based on the method and all arguments.
187-
* @param processor the processor to add
185+
* Set the {@link ConversionService} to use where input values need to
186+
* be formatted as Strings.
187+
* <p>By default, this is {@link DefaultFormattingConversionService}.
188188
* @return this same builder instance
189-
* @since 7.0
190189
*/
191-
public Builder httpRequestValuesProcessor(HttpRequestValues.Processor processor) {
192-
this.requestValuesProcessors.add(processor);
190+
public Builder conversionService(ConversionService conversionService) {
191+
this.conversionService = conversionService;
193192
return this;
194193
}
195194

196195
/**
197-
* Set the {@link ConversionService} to use where input values need to
198-
* be formatted as Strings.
199-
* <p>By default this is {@link DefaultFormattingConversionService}.
196+
* Register an {@link HttpRequestValues} processor that can further
197+
* customize request values based on the method and all arguments.
198+
* @param processor the processor to add
200199
* @return this same builder instance
200+
* @since 7.0
201201
*/
202-
public Builder conversionService(ConversionService conversionService) {
203-
this.conversionService = conversionService;
202+
public Builder httpRequestValuesProcessor(HttpRequestValues.Processor processor) {
203+
this.requestValuesProcessors.add(processor);
204204
return this;
205205
}
206206

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public ReactorHttpExchangeAdapterDecorator(HttpExchangeAdapter delegate) {
4343

4444

4545
/**
46-
* Return the wrapped delgate {@code HttpExchangeAdapter}.
46+
* Return the wrapped delegate {@code HttpExchangeAdapter}.
4747
*/
4848
@Override
4949
public ReactorHttpExchangeAdapter getHttpExchangeAdapter() {

spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.Optional;
2929
import java.util.Set;
3030
import java.util.function.BiFunction;
31+
import java.util.function.Consumer;
3132
import java.util.stream.Stream;
3233

3334
import io.micrometer.observation.tck.TestObservationRegistry;
@@ -79,17 +80,15 @@
7980
@SuppressWarnings("JUnitMalformedDeclaration")
8081
class RestClientAdapterTests {
8182

82-
private final MockWebServer anotherServer = anotherServer();
83+
private final MockWebServer anotherServer = new MockWebServer();
8384

8485

85-
@SuppressWarnings("ConstantValue")
8686
@AfterEach
8787
void shutdown() throws IOException {
88-
if (this.anotherServer != null) {
89-
this.anotherServer.shutdown();
90-
}
88+
this.anotherServer.shutdown();
9189
}
9290

91+
9392
@Retention(RetentionPolicy.RUNTIME)
9493
@Target(ElementType.METHOD)
9594
@ParameterizedTest
@@ -173,6 +172,9 @@ void greetingWithDynamicUri(MockWebServer server, Service service, TestObservati
173172

174173
@Test
175174
void greetingWithApiVersion() throws Exception {
175+
prepareResponse(response ->
176+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"));
177+
176178
RestClient restClient = RestClient.builder()
177179
.baseUrl(anotherServer.url("/").toString())
178180
.apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version"))
@@ -181,15 +183,18 @@ void greetingWithApiVersion() throws Exception {
181183
RestClientAdapter adapter = RestClientAdapter.create(restClient);
182184
Service service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class);
183185

184-
String response = service.getGreetingWithVersion();
186+
String actualResponse = service.getGreetingWithVersion();
185187

186188
RecordedRequest request = anotherServer.takeRequest();
187189
assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2");
188-
assertThat(response).isEqualTo("Hello Spring 2!");
190+
assertThat(actualResponse).isEqualTo("Hello Spring 2!");
189191
}
190192

191193
@ParameterizedAdapterTest
192194
void getWithUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException {
195+
prepareResponse(response ->
196+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"));
197+
193198
String url = this.anotherServer.url("/").toString();
194199
UriBuilderFactory factory = new DefaultUriBuilderFactory(url);
195200

@@ -205,6 +210,9 @@ void getWithUriBuilderFactory(MockWebServer server, Service service) throws Inte
205210

206211
@ParameterizedAdapterTest
207212
void getWithFactoryPathVariableAndRequestParam(MockWebServer server, Service service) throws InterruptedException {
213+
prepareResponse(response ->
214+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"));
215+
208216
String url = this.anotherServer.url("/").toString();
209217
UriBuilderFactory factory = new DefaultUriBuilderFactory(url);
210218

@@ -220,6 +228,9 @@ void getWithFactoryPathVariableAndRequestParam(MockWebServer server, Service ser
220228

221229
@ParameterizedAdapterTest
222230
void getWithIgnoredUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException {
231+
prepareResponse(response ->
232+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"));
233+
223234
URI dynamicUri = server.url("/greeting/123").uri();
224235
UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/").toString());
225236

@@ -306,6 +317,9 @@ void putWithSameNameCookies(MockWebServer server, Service service) throws Except
306317

307318
@Test
308319
void getInputStream() throws Exception {
320+
prepareResponse(response ->
321+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"));
322+
309323
InputStream inputStream = initService().getInputStream();
310324

311325
RecordedRequest request = this.anotherServer.takeRequest();
@@ -315,6 +329,9 @@ void getInputStream() throws Exception {
315329

316330
@Test
317331
void postOutputStream() throws Exception {
332+
prepareResponse(response ->
333+
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!"));
334+
318335
String body = "test stream";
319336
initService().postOutputStream(outputStream -> outputStream.write(body.getBytes()));
320337

@@ -323,13 +340,23 @@ void postOutputStream() throws Exception {
323340
assertThat(request.getBody().readUtf8()).isEqualTo(body);
324341
}
325342

326-
327-
private static MockWebServer anotherServer() {
328-
MockWebServer server = new MockWebServer();
343+
@Test
344+
void handleNotFoundException() {
329345
MockResponse response = new MockResponse();
330-
response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!");
331-
server.enqueue(response);
332-
return server;
346+
response.setResponseCode(404);
347+
this.anotherServer.enqueue(response);
348+
349+
RestClientAdapter clientAdapter = RestClientAdapter.create(
350+
RestClient.builder().baseUrl(this.anotherServer.url("/").toString()).build());
351+
352+
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(clientAdapter)
353+
.exchangeAdapterDecorator(NotFoundRestClientAdapterDecorator::new)
354+
.build();
355+
356+
ResponseEntity<String> responseEntity = factory.createClient(Service.class).getGreetingById("1");
357+
358+
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
359+
assertThat(responseEntity.getBody()).isNull();
333360
}
334361

335362
private Service initService() {
@@ -339,6 +366,12 @@ private Service initService() {
339366
return HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class);
340367
}
341368

369+
private void prepareResponse(Consumer<MockResponse> consumer) {
370+
MockResponse response = new MockResponse();
371+
consumer.accept(response);
372+
this.anotherServer.enqueue(response);
373+
}
374+
342375

343376
private interface Service {
344377

0 commit comments

Comments
 (0)