From 81d121797645c65cbfc5a85b73b3540e1ab0ea92 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 1 Feb 2017 15:29:46 -0500 Subject: [PATCH] Support for default headers and cookies Issue: SPR-15208 --- .../function/client/DefaultWebClient.java | 128 ++++++++++++++---- .../client/DefaultWebClientBuilder.java | 55 +++++++- .../reactive/function/client/WebClient.java | 22 +++ .../client/DefaultWebClientTests.java | 112 +++++++++++++++ 4 files changed, 290 insertions(+), 27 deletions(-) create mode 100644 spring-web-reactive/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java index 248c92eeb0f..239d716c37c 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java @@ -32,6 +32,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.util.DefaultUriBuilderFactory; @@ -50,10 +52,22 @@ class DefaultWebClient implements WebClient { private final UriBuilderFactory uriBuilderFactory; + private final HttpHeaders defaultHeaders; + + private final MultiValueMap defaultCookies; + + + DefaultWebClient(ExchangeFunction exchangeFunction, UriBuilderFactory factory, + HttpHeaders defaultHeaders, MultiValueMap defaultCookies) { - DefaultWebClient(ExchangeFunction exchangeFunction, UriBuilderFactory factory) { this.exchangeFunction = exchangeFunction; this.uriBuilderFactory = (factory != null ? factory : new DefaultUriBuilderFactory()); + + this.defaultHeaders = defaultHeaders != null ? + HttpHeaders.readOnlyHttpHeaders(defaultHeaders) : null; + + this.defaultCookies = defaultCookies != null ? + CollectionUtils.unmodifiableMultiValueMap(defaultCookies) : null; } @@ -110,7 +124,8 @@ class DefaultWebClient implements WebClient { @Override public WebClient filter(ExchangeFilterFunction filterFunction) { ExchangeFunction filteredExchangeFunction = this.exchangeFunction.filter(filterFunction); - return new DefaultWebClient(filteredExchangeFunction, this.uriBuilderFactory); + return new DefaultWebClient(filteredExchangeFunction, + this.uriBuilderFactory, this.defaultHeaders, this.defaultCookies); } @@ -123,11 +138,6 @@ class DefaultWebClient implements WebClient { this.httpMethod = httpMethod; } - @Override - public HeaderSpec uri(URI uri) { - return new DefaultHeaderSpec(ClientRequest.method(this.httpMethod, uri)); - } - @Override public HeaderSpec uri(String uriTemplate, Object... uriVariables) { return uri(getUriBuilderFactory().expand(uriTemplate, uriVariables)); @@ -137,24 +147,48 @@ class DefaultWebClient implements WebClient { public HeaderSpec uri(Function uriFunction) { return uri(uriFunction.apply(getUriBuilderFactory())); } + + @Override + public HeaderSpec uri(URI uri) { + return new DefaultHeaderSpec(this.httpMethod, uri); + } } private class DefaultHeaderSpec implements HeaderSpec { - private final ClientRequest.Builder requestBuilder; + private final HttpMethod httpMethod; + + private final URI uri; - private final HttpHeaders headers = new HttpHeaders(); + private HttpHeaders headers; + + private MultiValueMap cookies; + + + DefaultHeaderSpec(HttpMethod httpMethod, URI uri) { + this.httpMethod = httpMethod; + this.uri = uri; + } - DefaultHeaderSpec(ClientRequest.Builder requestBuilder) { - this.requestBuilder = requestBuilder; + private HttpHeaders getHeaders() { + if (this.headers == null) { + this.headers = new HttpHeaders(); + } + return this.headers; } + private MultiValueMap getCookies() { + if (this.cookies == null) { + this.cookies = new LinkedMultiValueMap<>(4); + } + return this.cookies; + } @Override public DefaultHeaderSpec header(String headerName, String... headerValues) { for (String headerValue : headerValues) { - this.headers.add(headerName, headerValue); + getHeaders().add(headerName, headerValue); } return this; } @@ -162,44 +196,46 @@ class DefaultWebClient implements WebClient { @Override public DefaultHeaderSpec headers(HttpHeaders headers) { if (headers != null) { - this.headers.putAll(headers); + getHeaders().putAll(headers); } return this; } @Override public DefaultHeaderSpec accept(MediaType... acceptableMediaTypes) { - this.headers.setAccept(Arrays.asList(acceptableMediaTypes)); + getHeaders().setAccept(Arrays.asList(acceptableMediaTypes)); return this; } @Override public DefaultHeaderSpec acceptCharset(Charset... acceptableCharsets) { - this.headers.setAcceptCharset(Arrays.asList(acceptableCharsets)); + getHeaders().setAcceptCharset(Arrays.asList(acceptableCharsets)); return this; } @Override public DefaultHeaderSpec contentType(MediaType contentType) { - this.headers.setContentType(contentType); + getHeaders().setContentType(contentType); return this; } @Override public DefaultHeaderSpec contentLength(long contentLength) { - this.headers.setContentLength(contentLength); + getHeaders().setContentLength(contentLength); return this; } @Override public DefaultHeaderSpec cookie(String name, String value) { - this.requestBuilder.cookie(name, value); + getCookies().add(name, value); return this; } @Override public DefaultHeaderSpec cookies(MultiValueMap cookies) { - this.requestBuilder.cookies(cookies); + if (cookies != null) { + getCookies().putAll(cookies); + } return this; } @@ -207,33 +243,77 @@ class DefaultWebClient implements WebClient { public DefaultHeaderSpec ifModifiedSince(ZonedDateTime ifModifiedSince) { ZonedDateTime gmt = ifModifiedSince.withZoneSameInstant(ZoneId.of("GMT")); String headerValue = DateTimeFormatter.RFC_1123_DATE_TIME.format(gmt); - this.headers.set(HttpHeaders.IF_MODIFIED_SINCE, headerValue); + getHeaders().set(HttpHeaders.IF_MODIFIED_SINCE, headerValue); return this; } @Override public DefaultHeaderSpec ifNoneMatch(String... ifNoneMatches) { - this.headers.setIfNoneMatch(Arrays.asList(ifNoneMatches)); + getHeaders().setIfNoneMatch(Arrays.asList(ifNoneMatches)); return this; } @Override public Mono exchange() { - ClientRequest request = this.requestBuilder.headers(this.headers).build(); + ClientRequest request = initRequestBuilder().build(); return getExchangeFunction().exchange(request); } @Override public Mono exchange(BodyInserter inserter) { - ClientRequest request = this.requestBuilder.headers(this.headers).body(inserter); + ClientRequest request = initRequestBuilder().body(inserter); return getExchangeFunction().exchange(request); } @Override public > Mono exchange(S publisher, Class elementClass) { - ClientRequest request = this.requestBuilder.headers(this.headers).body(publisher, elementClass); + ClientRequest request = initRequestBuilder().headers(this.headers).body(publisher, elementClass); return getExchangeFunction().exchange(request); } + + private ClientRequest.Builder initRequestBuilder() { + return ClientRequest.method(this.httpMethod, this.uri).headers(initHeaders()).cookies(initCookies()); + } + + private HttpHeaders initHeaders() { + if (CollectionUtils.isEmpty(defaultHeaders) && CollectionUtils.isEmpty(this.headers)) { + return null; + } + else if (CollectionUtils.isEmpty(defaultHeaders)) { + return this.headers; + } + else if (CollectionUtils.isEmpty(this.headers)) { + return defaultHeaders; + } + else { + HttpHeaders result = new HttpHeaders(); + result.putAll(this.headers); + defaultHeaders.forEach((name, values) -> { + if (!this.headers.containsKey(name)) { + values.forEach(value -> result.add(name, value)); + } + }); + return result; + } + } + + private MultiValueMap initCookies() { + if (CollectionUtils.isEmpty(defaultCookies) && CollectionUtils.isEmpty(this.cookies)) { + return null; + } + else if (CollectionUtils.isEmpty(defaultCookies)) { + return this.cookies; + } + else if (CollectionUtils.isEmpty(this.cookies)) { + return defaultCookies; + } + else { + MultiValueMap result = new LinkedMultiValueMap<>(); + result.putAll(this.cookies); + defaultCookies.forEach(result::putIfAbsent); + return result; + } + } } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java index 7b8a5036892..c78ba810eec 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java @@ -16,9 +16,14 @@ package org.springframework.web.reactive.function.client; +import java.util.Arrays; + +import org.springframework.http.HttpHeaders; import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.util.DefaultUriBuilderFactory; import org.springframework.web.util.UriBuilderFactory; @@ -36,6 +41,12 @@ class DefaultWebClientBuilder implements WebClient.Builder { private ExchangeStrategies exchangeStrategies = ExchangeStrategies.withDefaults(); + private ExchangeFunction exchangeFunction; + + private HttpHeaders defaultHeaders; + + private MultiValueMap defaultCookies; + public DefaultWebClientBuilder(String baseUrl) { this(new DefaultUriBuilderFactory(baseUrl)); @@ -60,11 +71,49 @@ class DefaultWebClientBuilder implements WebClient.Builder { return this; } + @Override + public WebClient.Builder exchangeFunction(ExchangeFunction exchangeFunction) { + this.exchangeFunction = exchangeFunction; + return this; + } + + @Override + public WebClient.Builder defaultHeader(String headerName, String... headerValues) { + if (this.defaultHeaders == null) { + this.defaultHeaders = new HttpHeaders(); + } + for (String headerValue : headerValues) { + this.defaultHeaders.add(headerName, headerValue); + } + return this; + } + + @Override + public WebClient.Builder defaultCookie(String cookieName, String... cookieValues) { + if (this.defaultCookies == null) { + this.defaultCookies = new LinkedMultiValueMap<>(4); + } + this.defaultCookies.addAll(cookieName, Arrays.asList(cookieValues)); + return this; + } + @Override public WebClient build() { - ClientHttpConnector connector = this.connector != null ? this.connector : new ReactorClientHttpConnector(); - ExchangeFunction exchangeFunction = ExchangeFunctions.create(connector, this.exchangeStrategies); - return new DefaultWebClient(exchangeFunction, this.uriBuilderFactory); + return new DefaultWebClient(initExchangeFunction(), + this.uriBuilderFactory, this.defaultHeaders, this.defaultCookies); + } + + private ExchangeFunction initExchangeFunction() { + if (this.exchangeFunction != null) { + return this.exchangeFunction; + } + else if (this.connector != null) { + return ExchangeFunctions.create(this.connector, this.exchangeStrategies); + } + + else { + return ExchangeFunctions.create(new ReactorClientHttpConnector(), this.exchangeStrategies); + } } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/WebClient.java index 0700fc3f482..43c03f744ca 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/WebClient.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/client/WebClient.java @@ -173,6 +173,28 @@ public interface WebClient { */ Builder exchangeStrategies(ExchangeStrategies strategies); + /** + * Configure directly an {@link ExchangeFunction} instead of separately + * providing a {@link ClientHttpConnector} and/or + * {@link ExchangeStrategies}. + * @param exchangeFunction the exchange function to use + */ + Builder exchangeFunction(ExchangeFunction exchangeFunction); + + /** + * Add the given header to all requests that haven't added it. + * @param headerName the header name + * @param headerValues the header values + */ + Builder defaultHeader(String headerName, String... headerValues); + + /** + * Add the given header to all requests that haven't added it. + * @param cookieName the cookie name + * @param cookieValues the cookie values + */ + Builder defaultCookie(String cookieName, String... cookieValues); + /** * Builder the {@link WebClient} instance. */ diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java new file mode 100644 index 00000000000..ad7e075acb1 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/client/DefaultWebClientTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2017 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.reactive.function.client; + +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DefaultWebClient}. + * @author Rossen Stoyanchev + */ +public class DefaultWebClientTests { + + private ExchangeFunction exchangeFunction; + + @Captor + private ArgumentCaptor> captor; + + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + this.exchangeFunction = mock(ExchangeFunction.class); + when(this.exchangeFunction.exchange(captor.capture())).thenReturn(Mono.empty()); + } + + + @Test + public void basic() throws Exception { + WebClient client = builder().build(); + client.get().uri("/path").exchange(); + + ClientRequest request = verifyExchange(); + assertEquals("/base/path", request.url().toString()); + assertEquals(new HttpHeaders(), request.headers()); + assertEquals(Collections.emptyMap(), request.cookies()); + } + + @Test + public void requestHeaderAndCookie() throws Exception { + WebClient client = builder().build(); + client.get().uri("/path").accept(MediaType.APPLICATION_JSON).cookie("id", "123").exchange(); + + ClientRequest request = verifyExchange(); + assertEquals("application/json", request.headers().getFirst("Accept")); + assertEquals("123", request.cookies().getFirst("id")); + verifyNoMoreInteractions(this.exchangeFunction); + } + + @Test + public void defaultHeaderAndCookie() throws Exception { + WebClient client = builder().defaultHeader("Accept", "application/json").defaultCookie("id", "123").build(); + client.get().uri("/path").exchange(); + + ClientRequest request = verifyExchange(); + assertEquals("application/json", request.headers().getFirst("Accept")); + assertEquals("123", request.cookies().getFirst("id")); + verifyNoMoreInteractions(this.exchangeFunction); + } + + @Test + public void defaultHeaderAndCookieOverrides() throws Exception { + WebClient client = builder().defaultHeader("Accept", "application/json").defaultCookie("id", "123").build(); + client.get().uri("/path").header("Accept", "application/xml").cookie("id", "456").exchange(); + + ClientRequest request = verifyExchange(); + assertEquals("application/xml", request.headers().getFirst("Accept")); + assertEquals("456", request.cookies().getFirst("id")); + verifyNoMoreInteractions(this.exchangeFunction); + } + + + private WebClient.Builder builder() { + return WebClient.builder("/base").exchangeFunction(this.exchangeFunction); + } + + private ClientRequest verifyExchange() { + ClientRequest request = this.captor.getValue(); + Mockito.verify(this.exchangeFunction).exchange(request); + verifyNoMoreInteractions(this.exchangeFunction); + return request; + } + +}