From a2b90d9e9a64c06be5d1f91990041f9cd5aca1af Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 17 Jun 2025 17:32:04 +0100 Subject: [PATCH] Add HttpExchangeAdapter decoration Closes gh-35059 --- .../invoker/HttpExchangeAdapterDecorator.java | 79 +++++++++++ .../invoker/HttpServiceProxyFactory.java | 16 ++- .../ReactorHttpExchangeAdapterDecorator.java | 129 ++++++++++++++++++ .../invoker/HttpServiceProxyFactoryTests.java | 68 +++++++++ 4 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapterDecorator.java create mode 100644 spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapterDecorator.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapterDecorator.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapterDecorator.java new file mode 100644 index 00000000000..f9f77510880 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpExchangeAdapterDecorator.java @@ -0,0 +1,79 @@ +/* + * 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.service.invoker; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +/** + * {@link HttpExchangeAdapter} that wraps and delegates to another adapter instance. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class HttpExchangeAdapterDecorator implements HttpExchangeAdapter { + + private final HttpExchangeAdapter delegate; + + + public HttpExchangeAdapterDecorator(HttpExchangeAdapter delegate) { + this.delegate = delegate; + } + + + /** + * Return the wrapped delgate {@code HttpExchangeAdapter}. + */ + public HttpExchangeAdapter getHttpExchangeAdapter() { + return this.delegate; + } + + + @Override + public boolean supportsRequestAttributes() { + return this.delegate.supportsRequestAttributes(); + } + + @Override + public void exchange(HttpRequestValues requestValues) { + this.delegate.exchange(requestValues); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + return this.delegate.exchangeForHeaders(requestValues); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return this.delegate.exchangeForBody(requestValues, bodyType); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + return this.delegate.exchangeForBodilessEntity(requestValues); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return this.delegate.exchangeForEntity(requestValues, bodyType); + } + +} 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 202c6bb6f0c..30b52d906db 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 @@ -136,6 +136,8 @@ public final class HttpServiceProxyFactory { private @Nullable HttpExchangeAdapter exchangeAdapter; + private Function exchangeAdapterDecorator = Function.identity(); + private final List customArgumentResolvers = new ArrayList<>(); private final List requestValuesProcessors = new ArrayList<>(); @@ -158,6 +160,17 @@ public final class HttpServiceProxyFactory { return this; } + /** + * Provide a function to wrap the configured {@code HttpExchangeAdapter}. + * @param decorator a client adapted to {@link HttpExchangeAdapter} + * @return this same builder instance + * @since 7.0 + */ + public Builder exchangeAdapterDecorator(Function decorator) { + this.exchangeAdapterDecorator = this.exchangeAdapterDecorator.andThen(decorator); + return this; + } + /** * Register a custom argument resolver, invoked ahead of default resolvers. * @param resolver the resolver to add @@ -207,9 +220,10 @@ public final class HttpServiceProxyFactory { */ public HttpServiceProxyFactory build() { Assert.notNull(this.exchangeAdapter, "HttpClientAdapter is required"); + HttpExchangeAdapter adapterToUse = this.exchangeAdapterDecorator.apply(this.exchangeAdapter); return new HttpServiceProxyFactory( - this.exchangeAdapter, initArgumentResolvers(), this.requestValuesProcessors, + adapterToUse, initArgumentResolvers(), this.requestValuesProcessors, this.embeddedValueResolver); } 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 new file mode 100644 index 00000000000..0559ae4cf0f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactorHttpExchangeAdapterDecorator.java @@ -0,0 +1,129 @@ +/* + * 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.service.invoker; + +import java.time.Duration; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +/** + * {@link ReactorHttpExchangeAdapter} that wraps and delegates to another adapter instance. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class ReactorHttpExchangeAdapterDecorator extends HttpExchangeAdapterDecorator + implements ReactorHttpExchangeAdapter { + + + public ReactorHttpExchangeAdapterDecorator(HttpExchangeAdapter delegate) { + super(delegate); + } + + + /** + * Return the wrapped delgate {@code HttpExchangeAdapter}. + */ + @Override + public ReactorHttpExchangeAdapter getHttpExchangeAdapter() { + return (ReactorHttpExchangeAdapter) super.getHttpExchangeAdapter(); + } + + + @Override + public boolean supportsRequestAttributes() { + return getHttpExchangeAdapter().supportsRequestAttributes(); + } + + @Override + public void exchange(HttpRequestValues requestValues) { + getHttpExchangeAdapter().exchange(requestValues); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + return getHttpExchangeAdapter().exchangeForHeaders(requestValues); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return getHttpExchangeAdapter().exchangeForBody(requestValues, bodyType); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + return getHttpExchangeAdapter().exchangeForBodilessEntity(requestValues); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return getHttpExchangeAdapter().exchangeForEntity(requestValues, bodyType); + } + + @Override + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + return getHttpExchangeAdapter().getReactiveAdapterRegistry(); + } + + @Override + public @Nullable Duration getBlockTimeout() { + return getHttpExchangeAdapter().getBlockTimeout(); + } + + @Override + public Mono exchangeForMono(HttpRequestValues requestValues) { + return getHttpExchangeAdapter().exchangeForMono(requestValues); + } + + @Override + public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { + return getHttpExchangeAdapter().exchangeForHeadersMono(requestValues); + } + + @Override + public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return getHttpExchangeAdapter().exchangeForBodyMono(requestValues, bodyType); + } + + @Override + public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return getHttpExchangeAdapter().exchangeForBodyFlux(requestValues, bodyType); + } + + @Override + public Mono> exchangeForBodilessEntityMono(HttpRequestValues values) { + return getHttpExchangeAdapter().exchangeForBodilessEntityMono(values); + } + + @Override + public Mono> exchangeForEntityMono(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return getHttpExchangeAdapter().exchangeForEntityMono(values, bodyType); + } + + @Override + public Mono>> exchangeForEntityFlux(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return getHttpExchangeAdapter().exchangeForEntityFlux(values, bodyType); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java new file mode 100644 index 00000000000..708945b71d7 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java @@ -0,0 +1,68 @@ +/* + * 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.service.invoker; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.service.annotation.GetExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link HttpServiceProxyFactory}. + * @author Rossen Stoyanchev + */ +public class HttpServiceProxyFactoryTests { + + + @Test + void httpExchangeAdapterDecorator() { + + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(mock(HttpExchangeAdapter.class)) + .exchangeAdapterDecorator(TestDecorator::new) + .build(); + + Service service = factory.createClient(Service.class); + assertThat(service.execute()).isEqualTo("decorated"); + } + + + + private interface Service { + + @GetExchange + String execute(); + } + + + private static class TestDecorator extends HttpExchangeAdapterDecorator { + + public TestDecorator(HttpExchangeAdapter delegate) { + super(delegate); + } + + @SuppressWarnings("unchecked") + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return (T) "decorated"; + } + } + +}