diff --git a/spring-web/src/main/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptionChain.java b/spring-web/src/main/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptionChain.java new file mode 100644 index 00000000000..24cfff551e9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptionChain.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.reactive; + +import java.net.URI; +import java.util.function.Consumer; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ClientHttpResponse; + +/** + * Delegate to the next {@link ClientHttpRequestInterceptor} in the chain. + * + * @author Brian Clozel + * @since 5.0 + */ +public interface ClientHttpRequestInterceptionChain { + + /** + * Delegate to the next {@link ClientHttpRequestInterceptor} in the chain. + * + * @param method the HTTP request method + * @param uri the HTTP request URI + * @param requestCallback a function that can customize the request + * by changing the HTTP request headers with {@code HttpMessage.getHeaders()}. + * @return a publisher of the resulting {@link ClientHttpResponse} + */ + Mono intercept(HttpMethod method, URI uri, Consumer requestCallback); + +} diff --git a/spring-web/src/main/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptor.java b/spring-web/src/main/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptor.java new file mode 100644 index 00000000000..5c05ddb8bd1 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptor.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.reactive; + +import java.net.URI; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ClientHttpResponse; + +/** + * Contract for chain-based, interception processing of client http requests + * that may be used to implement cross-cutting requirements such + * as security, timeouts, caching, and others. + * + *

Implementations of this interface can be + * {@link WebClient#setInterceptors(List) registered} with the {@link WebClient}. + * + * @author Brian Clozel + * @see org.springframework.web.client.reactive.WebClient + * @since 5.0 + */ +@FunctionalInterface +public interface ClientHttpRequestInterceptor { + + /** + * Intercept the client HTTP request + * + *

The provided {@link ClientHttpRequestInterceptionChain} + * instance allows the interceptor to delegate the request + * to the next interceptor in the chain. + * + *

An implementation might follow this pattern: + *

    + *
  1. Examine the {@link HttpMethod method} and {@link URI uri}
  2. + *
  3. Optionally change those when delegating to the next interceptor + * with the {@code ClientHttpRequestInterceptionChain}.
  4. + *
  5. Optionally transform the HTTP message given as an + * argument of the request callback in + * {@code chain.intercept(method, uri, requestCallback)}.
  6. + *
  7. Optionally transform the response before returning it.
  8. + *
+ * + * @param method the HTTP request method + * @param uri the HTTP request URI + * @param chain the request interception chain + * @return a publisher of the {@link ClientHttpResponse} + */ + Mono intercept(HttpMethod method, URI uri, ClientHttpRequestInterceptionChain chain); +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/client/reactive/WebClient.java b/spring-web/src/main/java/org/springframework/web/client/reactive/WebClient.java index 50329ef7d6e..721d77f4dd3 100644 --- a/spring-web/src/main/java/org/springframework/web/client/reactive/WebClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/reactive/WebClient.java @@ -16,6 +16,7 @@ package org.springframework.web.client.reactive; +import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -33,6 +34,8 @@ import org.springframework.core.codec.ByteBufferEncoder; import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.core.codec.ResourceDecoder; import org.springframework.core.codec.StringDecoder; +import org.springframework.http.HttpMessage; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ClientHttpConnector; @@ -47,6 +50,7 @@ import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.codec.xml.Jaxb2XmlDecoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; +import org.springframework.util.Assert; import org.springframework.util.ClassUtils; /** @@ -94,6 +98,8 @@ public final class WebClient { private ClientHttpConnector clientHttpConnector; + private List interceptors; + private final DefaultWebClientConfig webClientConfig; @@ -172,6 +178,15 @@ public final class WebClient { this.webClientConfig.setResponseErrorHandler(responseErrorHandler); } + /** + * Set the list of {@link ClientHttpRequestInterceptor} to use + * for intercepting client HTTP requests + */ + public void setInterceptors(List interceptors) { + this.interceptors = (interceptors != null ? + Collections.unmodifiableList(interceptors) : Collections.emptyList()); + } + /** * Perform the actual HTTP request/response exchange * @@ -186,10 +201,12 @@ public final class WebClient { public WebResponseActions perform(ClientWebRequestBuilder builder) { ClientWebRequest clientWebRequest = builder.build(); + DefaultClientHttpRequestInterceptionChain interception = + new DefaultClientHttpRequestInterceptionChain(this.clientHttpConnector, + this.interceptors, clientWebRequest); - final Mono clientResponse = this.clientHttpConnector - .connect(clientWebRequest.getMethod(), clientWebRequest.getUrl(), - new DefaultRequestCallback(clientWebRequest)) + final Mono clientResponse = interception + .intercept(clientWebRequest.getMethod(), clientWebRequest.getUrl(), null) .log("org.springframework.web.client.reactive", Level.FINE); return new WebResponseActions() { @@ -253,12 +270,15 @@ public final class WebClient { private final ClientWebRequest clientWebRequest; + private final List> requestCustomizers; + - public DefaultRequestCallback(ClientWebRequest clientWebRequest) { + public DefaultRequestCallback(ClientWebRequest clientWebRequest, + List> requestCustomizers) { this.clientWebRequest = clientWebRequest; + this.requestCustomizers = requestCustomizers; } - @Override public Mono apply(ClientHttpRequest clientHttpRequest) { clientHttpRequest.getHeaders().putAll(this.clientWebRequest.getHttpHeaders()); @@ -269,6 +289,9 @@ public final class WebClient { this.clientWebRequest.getCookies().values() .stream().flatMap(cookies -> cookies.stream()) .forEach(cookie -> clientHttpRequest.getCookies().add(cookie.getName(), cookie)); + + this.requestCustomizers.forEach(customizer -> customizer.accept(clientHttpRequest)); + if (this.clientWebRequest.getBody() != null) { return writeRequestBody(this.clientWebRequest.getBody(), this.clientWebRequest.getElementType(), @@ -279,7 +302,7 @@ public final class WebClient { } } - @SuppressWarnings({ "unchecked", "rawtypes" }) + @SuppressWarnings({"unchecked", "rawtypes"}) protected Mono writeRequestBody(Publisher content, ResolvableType requestType, ClientHttpRequest request, List> messageWriters) { @@ -301,4 +324,47 @@ public final class WebClient { } } + protected class DefaultClientHttpRequestInterceptionChain implements ClientHttpRequestInterceptionChain { + + private final ClientHttpConnector connector; + + private final List interceptors; + + private final ClientWebRequest clientWebRequest; + + private final List> requestCustomizers; + + private int index; + + public DefaultClientHttpRequestInterceptionChain(ClientHttpConnector connector, + List interceptors, + ClientWebRequest clientWebRequest) { + + Assert.notNull(connector, "'connector' should not be null"); + this.connector = connector; + this.interceptors = interceptors; + this.clientWebRequest = clientWebRequest; + this.requestCustomizers = new ArrayList<>(); + this.index = 0; + } + + @Override + public Mono intercept(HttpMethod method, URI uri, + Consumer requestCustomizer) { + + if (requestCustomizer != null) { + this.requestCustomizers.add(requestCustomizer); + } + if (this.interceptors != null && this.index < this.interceptors.size()) { + ClientHttpRequestInterceptor interceptor = this.interceptors.get(this.index++); + return interceptor.intercept(method, uri, this); + } + else { + return this.connector.connect(method, uri, + new DefaultRequestCallback(this.clientWebRequest, this.requestCustomizers)); + } + } + + } + } \ No newline at end of file diff --git a/spring-web/src/test/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptorTests.java b/spring-web/src/test/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptorTests.java new file mode 100644 index 00000000000..f18d336077a --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/reactive/ClientHttpRequestInterceptorTests.java @@ -0,0 +1,165 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.reactive; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.springframework.web.client.reactive.ClientWebRequestBuilders.*; +import static org.springframework.web.client.reactive.ResponseExtractors.*; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.tests.TestSubscriber; +import org.springframework.web.client.reactive.test.MockClientHttpRequest; +import org.springframework.web.client.reactive.test.MockClientHttpResponse; + +/** + * @author Brian Clozel + */ +public class ClientHttpRequestInterceptorTests { + + private MockClientHttpRequest mockRequest; + + private MockClientHttpResponse mockResponse; + + private MockClientHttpConnector mockClientHttpConnector; + + private WebClient webClient; + + + @Before + public void setUp() throws Exception { + this.mockClientHttpConnector = new MockClientHttpConnector(); + this.webClient = new WebClient(this.mockClientHttpConnector); + this.mockResponse = new MockClientHttpResponse(); + this.mockResponse.setStatus(HttpStatus.OK); + this.mockResponse.getHeaders().setContentType(MediaType.TEXT_PLAIN); + this.mockResponse.setBody("Spring Framework"); + } + + @Test + public void shouldExecuteInterceptors() throws Exception { + List interceptors = new ArrayList<>(); + interceptors.add(new NoOpInterceptor()); + interceptors.add(new NoOpInterceptor()); + interceptors.add(new NoOpInterceptor()); + this.webClient.setInterceptors(interceptors); + + Mono result = this.webClient.perform(get("http://example.org/resource")) + .extract(body(String.class)); + + TestSubscriber.subscribe(result) + .assertNoError() + .assertValues("Spring Framework") + .assertComplete(); + interceptors.stream().forEach(interceptor -> { + Assert.assertTrue(((NoOpInterceptor) interceptor).invoked); + }); + } + + @Test + public void shouldChangeRequest() throws Exception { + ClientHttpRequestInterceptor interceptor = new ClientHttpRequestInterceptor() { + @Override + public Mono intercept(HttpMethod method, URI uri, + ClientHttpRequestInterceptionChain interception) { + + return interception.intercept(HttpMethod.POST, URI.create("http://example.org/other"), + (request) -> { + request.getHeaders().set("X-Custom", "Spring Framework"); + }); + } + }; + this.webClient.setInterceptors(Collections.singletonList(interceptor)); + + Mono result = this.webClient.perform(get("http://example.org/resource")) + .extract(body(String.class)); + + TestSubscriber.subscribe(result) + .assertNoError() + .assertValues("Spring Framework") + .assertComplete(); + + assertThat(this.mockRequest.getMethod(), is(HttpMethod.POST)); + assertThat(this.mockRequest.getURI().toString(), is("http://example.org/other")); + assertThat(this.mockRequest.getHeaders().getFirst("X-Custom"), is("Spring Framework")); + } + + @Test + public void shouldShortCircuitConnector() throws Exception { + + MockClientHttpResponse otherResponse = new MockClientHttpResponse(); + otherResponse.setStatus(HttpStatus.OK); + otherResponse.setBody("Other content"); + + List interceptors = new ArrayList<>(); + interceptors.add((method, uri, interception) -> Mono.just(otherResponse)); + interceptors.add(new NoOpInterceptor()); + this.webClient.setInterceptors(interceptors); + + Mono result = this.webClient.perform(get("http://example.org/resource")) + .extract(body(String.class)); + + TestSubscriber.subscribe(result) + .assertNoError() + .assertValues("Other content") + .assertComplete(); + + assertFalse(((NoOpInterceptor) interceptors.get(1)).invoked); + } + + private class MockClientHttpConnector implements ClientHttpConnector { + + @Override + public Mono connect(HttpMethod method, URI uri, + Function> requestCallback) { + + mockRequest = new MockClientHttpRequest(method, uri); + return requestCallback.apply(mockRequest).then(Mono.just(mockResponse)); + } + } + + + private static class NoOpInterceptor implements ClientHttpRequestInterceptor { + + public boolean invoked = false; + + @Override + public Mono intercept(HttpMethod method, URI uri, + ClientHttpRequestInterceptionChain interception) { + + this.invoked = true; + return interception.intercept(method, uri, (request) -> { }); + } + } +}