From 74b4c028819c67b90d4d06d352d3083d91d8a3f8 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 5 Jul 2017 14:37:23 +0200 Subject: [PATCH] Add ClientRequest attributes This commit introduces client-side request attributes, similar to those found on the server-side. The attributes can be used, for instance, for passing on request-specific information to a globally registered ExchangeFilterFunction. The client request builder, as well as WebClient.RequestHeadersSpec and WebTestClient.RequestHeaderSpec, add methods for adding a single attribute, as well as manipulating the entire attributes map. The client request itself adds a accessor for the (immutable) attributes map. This commit also introduces a new variant of the basic authentication filter in ExchangeFilterFunctions. This variant takes the username and password from well-known attributes. Issue: SPR-15691 --- .../reactive/server/DefaultWebTestClient.java | 21 ++++++-- .../web/reactive/server/WebTestClient.java | 17 ++++++ .../function/client/ClientRequest.java | 24 +++++++++ .../client/DefaultClientRequestBuilder.java | 30 ++++++++++- .../function/client/DefaultWebClient.java | 27 +++++++++- .../client/ExchangeFilterFunctions.java | 52 ++++++++++++++++++- .../reactive/function/client/WebClient.java | 17 ++++++ .../client/DefaultWebClientTests.java | 12 +++++ .../client/ExchangeFilterFunctionsTests.java | 21 +++++++- 9 files changed, 212 insertions(+), 9 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 0d9f12ba7ae..c394771a0eb 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -51,9 +51,11 @@ import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriBuilder; -import static java.nio.charset.StandardCharsets.*; -import static org.springframework.test.util.AssertionErrors.*; -import static org.springframework.web.reactive.function.BodyExtractors.*; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.web.reactive.function.BodyExtractors.toFlux; +import static org.springframework.web.reactive.function.BodyExtractors.toMono; /** * Default implementation of {@link WebTestClient}. @@ -196,6 +198,19 @@ class DefaultWebTestClient implements WebTestClient { return this; } + @Override + public RequestBodySpec attribute(String name, Object value) { + this.bodySpec.attribute(name, value); + return this; + } + + @Override + public RequestBodySpec attributes( + Consumer> attributesConsumer) { + this.bodySpec.attributes(attributesConsumer); + return this; + } + @Override public RequestBodySpec accept(MediaType... acceptableMediaTypes) { this.bodySpec.accept(acceptableMediaTypes); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 2e3d7bc970c..04276b820b1 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -508,6 +508,23 @@ public interface WebTestClient { */ S headers(Consumer headersConsumer); + /** + * Set the attribute with the given name to the given value. + * @param name the name of the attribute to add + * @param value the value of the attribute to add + * @return this builder + */ + S attribute(String name, Object value); + + /** + * Manipulate the request attributes with the given consumer. The attributes provided to + * the consumer are "live", so that the consumer can be used to inspect attributes, + * remove attributes, or use any of the other map-provided methods. + * @param attributesConsumer a function that consumes the attributes + * @return this builder + */ + S attributes(Consumer> attributesConsumer); + /** * Perform the exchange without a request body. * @return spec for decoding the response diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientRequest.java index 8b9334f4256..e89b7da9256 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientRequest.java @@ -17,6 +17,7 @@ package org.springframework.web.reactive.function.client; import java.net.URI; +import java.util.Map; import java.util.function.Consumer; import org.reactivestreams.Publisher; @@ -68,6 +69,11 @@ public interface ClientRequest { */ BodyInserter body(); + /** + * Return the attributes of this request. + */ + Map attributes(); + /** * Writes this request to the given {@link ClientHttpRequest}. * @@ -90,6 +96,7 @@ public interface ClientRequest { return new DefaultClientRequestBuilder(other.method(), other.url()) .headers(headers -> headers.addAll(other.headers())) .cookies(cookies -> cookies.addAll(other.cookies())) + .attributes(attributes -> attributes.putAll(other.attributes())) .body(other.body()); } @@ -165,6 +172,23 @@ public interface ClientRequest { */ > Builder body(P publisher, Class elementClass); + /** + * Set the attribute with the given name to the given value. + * @param name the name of the attribute to add + * @param value the value of the attribute to add + * @return this builder + */ + Builder attribute(String name, Object value); + + /** + * Manipulate the request attributes with the given consumer. The attributes provided to + * the consumer are "live", so that the consumer can be used to inspect attributes, + * remove attributes, or use any of the other map-provided methods. + * @param attributesConsumer a function that consumes the attributes + * @return this builder + */ + Builder attributes(Consumer> attributesConsumer); + /** * Builds the request entity with no body. * @return the request entity diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java index 4afeb41f27a..a50c4d4c4ae 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java @@ -18,6 +18,7 @@ package org.springframework.web.reactive.function.client; import java.net.URI; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -55,6 +56,8 @@ class DefaultClientRequestBuilder implements ClientRequest.Builder { private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + private final Map attributes = new LinkedHashMap<>(); + private BodyInserter inserter = BodyInserters.empty(); @@ -103,6 +106,19 @@ class DefaultClientRequestBuilder implements ClientRequest.Builder { return this; } + @Override + public ClientRequest.Builder attribute(String name, Object value) { + this.attributes.put(name, value); + return this; + } + + @Override + public ClientRequest.Builder attributes(Consumer> attributesConsumer) { + Assert.notNull(attributesConsumer, "'attributesConsumer' must not be null"); + attributesConsumer.accept(this.attributes); + return this; + } + @Override public ClientRequest.Builder body(BodyInserter inserter) { this.inserter = inserter; @@ -112,7 +128,7 @@ class DefaultClientRequestBuilder implements ClientRequest.Builder { @Override public ClientRequest build() { return new BodyInserterRequest(this.method, this.url, this.headers, this.cookies, - this.inserter); + this.inserter, this.attributes); } @@ -128,14 +144,19 @@ class DefaultClientRequestBuilder implements ClientRequest.Builder { private final BodyInserter inserter; + private final Map attributes; + public BodyInserterRequest(HttpMethod method, URI url, HttpHeaders headers, - MultiValueMap cookies, BodyInserter inserter) { + MultiValueMap cookies, + BodyInserter inserter, + Map attributes) { this.method = method; this.url = url; this.headers = HttpHeaders.readOnlyHttpHeaders(headers); this.cookies = CollectionUtils.unmodifiableMultiValueMap(cookies); this.inserter = inserter; + this.attributes = Collections.unmodifiableMap(attributes); } @Override @@ -163,6 +184,11 @@ class DefaultClientRequestBuilder implements ClientRequest.Builder { return this.inserter; } + @Override + public Map attributes() { + return this.attributes; + } + @Override public Mono writeTo(ClientHttpRequest request, ExchangeStrategies strategies) { HttpHeaders requestHeaders = request.getHeaders(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java index c7b77b8cf56..9c2c85d56e6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java @@ -22,6 +22,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -181,6 +182,9 @@ class DefaultWebClient implements WebClient { @Nullable private BodyInserter inserter; + @Nullable + private Map attributes; + DefaultRequestBodySpec(HttpMethod httpMethod, URI uri) { this.httpMethod = httpMethod; this.uri = uri; @@ -200,6 +204,13 @@ class DefaultWebClient implements WebClient { return this.cookies; } + private Map getAttributes() { + if (this.attributes == null) { + this.attributes = new LinkedHashMap<>(4); + } + return this.attributes; + } + @Override public DefaultRequestBodySpec header(String headerName, String... headerValues) { for (String headerValue : headerValues) { @@ -215,6 +226,19 @@ class DefaultWebClient implements WebClient { return this; } + @Override + public RequestBodySpec attribute(String name, Object value) { + getAttributes().put(name, value); + return this; + } + + @Override + public RequestBodySpec attributes(Consumer> attributesConsumer) { + Assert.notNull(attributesConsumer, "'attributesConsumer' must not be null"); + attributesConsumer.accept(getAttributes()); + return this; + } + @Override public DefaultRequestBodySpec accept(MediaType... acceptableMediaTypes) { getHeaders().setAccept(Arrays.asList(acceptableMediaTypes)); @@ -298,7 +322,8 @@ class DefaultWebClient implements WebClient { private ClientRequest.Builder initRequestBuilder() { return ClientRequest.method(this.httpMethod, this.uri) .headers(headers -> headers.addAll(initHeaders())) - .cookies(cookies -> cookies.addAll(initCookies())); + .cookies(cookies -> cookies.addAll(initCookies())) + .attributes(attributes -> attributes.putAll(getAttributes())); } private HttpHeaders initHeaders() { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctions.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctions.java index cdb4c9e4f03..315e626fa2d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctions.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctions.java @@ -18,6 +18,8 @@ package org.springframework.web.reactive.function.client; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Map; +import java.util.function.Function; import reactor.core.publisher.Mono; @@ -35,7 +37,21 @@ import org.springframework.util.Assert; public abstract class ExchangeFilterFunctions { /** - * Return a filter that adds an Authorization header for HTTP Basic Authentication. + * Name of the {@link ClientRequest} attribute that contains the username, as used by + * {@link #basicAuthentication()} + */ + public static final String USERNAME_ATTRIBUTE = ExchangeFilterFunctions.class.getName() + ".username"; + + /** + * Name of the {@link ClientRequest} attribute that contains the password, as used by + * {@link #basicAuthentication()} + */ + public static final String PASSWORD_ATTRIBUTE = ExchangeFilterFunctions.class.getName() + ".password"; + + + /** + * Return a filter that adds an Authorization header for HTTP Basic Authentication, based on + * the given username and password. * @param username the username to use * @param password the password to use * @return the {@link ExchangeFilterFunction} that adds the Authorization header @@ -44,9 +60,41 @@ public abstract class ExchangeFilterFunctions { Assert.notNull(username, "'username' must not be null"); Assert.notNull(password, "'password' must not be null"); + return basicAuthentication(r -> username, r -> password); + } + + /** + * Return a filter that adds an Authorization header for HTTP Basic Authentication, based on + * the username and password provided in the + * {@linkplain ClientRequest#attributes() request attributes}. + * @return the {@link ExchangeFilterFunction} that adds the Authorization header + * @see #USERNAME_ATTRIBUTE + * @see #PASSWORD_ATTRIBUTE + */ + public static ExchangeFilterFunction basicAuthentication() { + return basicAuthentication( + request -> getRequiredAttribute(request, USERNAME_ATTRIBUTE), + request -> getRequiredAttribute(request, PASSWORD_ATTRIBUTE) + ); + } + + private static String getRequiredAttribute(ClientRequest request, String key) { + Map attributes = request.attributes(); + if (attributes.containsKey(key)) { + return (String) attributes.get(key); + } else { + throw new IllegalStateException( + "Could not find request attribute with key \"" + key + "\""); + } + } + + private static ExchangeFilterFunction basicAuthentication(Function usernameFunction, + Function passwordFunction) { + return ExchangeFilterFunction.ofRequestProcessor( clientRequest -> { - String authorization = authorization(username, password); + String authorization = authorization(usernameFunction.apply(clientRequest), + passwordFunction.apply(clientRequest)); ClientRequest authorizedRequest = ClientRequest.from(clientRequest) .headers(headers -> { headers.set(HttpHeaders.AUTHORIZATION, authorization); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java index 9221f02834b..ab75b069d5b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java @@ -420,6 +420,23 @@ public interface WebClient { */ S headers(Consumer headersConsumer); + /** + * Set the attribute with the given name to the given value. + * @param name the name of the attribute to add + * @param value the value of the attribute to add + * @return this builder + */ + S attribute(String name, Object value); + + /** + * Manipulate the request attributes with the given consumer. The attributes provided to + * the consumer are "live", so that the consumer can be used to inspect attributes, + * remove attributes, or use any of the other map-provided methods. + * @param attributesConsumer a function that consumes the attributes + * @return this builder + */ + S attributes(Consumer> attributesConsumer); + /** * Exchange the request for a {@code ClientResponse} with full access * to the response status and headers before extracting the body. diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java index d9b9138ee10..50a7be7264d 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java @@ -145,6 +145,18 @@ public class DefaultWebClientTests { client2.mutate().defaultCookies(cookies -> assertEquals(2, cookies.size())); } + @Test + public void attributes() { + ExchangeFilterFunction filter = (request, next) -> { + assertEquals("bar", request.attributes().get("foo")); + return next.exchange(request); + }; + + WebClient client = builder().filter(filter).build(); + + client.get().uri("/path").attribute("foo", "bar").exchange(); + } + private WebClient.Builder builder() { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctionsTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctionsTests.java index 7fbcb364999..d26b7c15c06 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctionsTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/ExchangeFilterFunctionsTests.java @@ -25,7 +25,7 @@ import org.springframework.http.HttpHeaders; import static org.junit.Assert.*; import static org.mockito.Mockito.*; -import static org.springframework.http.HttpMethod.*; +import static org.springframework.http.HttpMethod.GET; /** * @author Arjen Poutsma @@ -98,4 +98,23 @@ public class ExchangeFilterFunctionsTests { assertEquals(response, result); } + @Test + public void basicAuthenticationAttributes() throws Exception { + ClientRequest request = ClientRequest.method(GET, URI.create("http://example.com")) + .attribute(ExchangeFilterFunctions.USERNAME_ATTRIBUTE, "foo") + .attribute(ExchangeFilterFunctions.PASSWORD_ATTRIBUTE, "bar").build(); + ClientResponse response = mock(ClientResponse.class); + + ExchangeFunction exchange = r -> { + assertTrue(r.headers().containsKey(HttpHeaders.AUTHORIZATION)); + assertTrue(r.headers().getFirst(HttpHeaders.AUTHORIZATION).startsWith("Basic ")); + return Mono.just(response); + }; + + ExchangeFilterFunction auth = ExchangeFilterFunctions.basicAuthentication(); + assertFalse(request.headers().containsKey(HttpHeaders.AUTHORIZATION)); + ClientResponse result = auth.filter(request, exchange).block(); + assertEquals(response, result); + } + }