From 8e26c8d94cd114be8c6fe007a672bb644bbe5682 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 28 Apr 2022 19:07:10 +0200 Subject: [PATCH] Add initial RequestHeaderArgumentResolver implementation and tests. --- .../invoker/HttpServiceProxyFactory.java | 1 + .../invoker/PathVariableArgumentResolver.java | 13 +- .../RequestHeaderArgumentResolver.java | 139 ++++++++++++ .../PathVariableArgumentResolverTests.java | 9 + .../RequestHeaderArgumentResolverTests.java | 198 ++++++++++++++++++ 5 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolver.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java 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 1ab49e2cdb91..a442fa58e486 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 @@ -189,6 +189,7 @@ private List initArgumentResolvers(ConversionServic List resolvers = new ArrayList<>(this.customResolvers); resolvers.add(new HttpMethodArgumentResolver()); resolvers.add(new PathVariableArgumentResolver(conversionService)); + resolvers.add(new RequestHeaderArgumentResolver(conversionService)); return resolvers; } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/PathVariableArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/PathVariableArgumentResolver.java index 0c80baea5471..371439239ac1 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/PathVariableArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/PathVariableArgumentResolver.java @@ -66,7 +66,7 @@ public boolean resolve( if (Map.class.isAssignableFrom(parameter.getParameterType())) { if (argument != null) { Assert.isInstanceOf(Map.class, argument); - ((Map) argument).forEach((key, value) -> + ((Map) argument).forEach((key, value) -> addUriParameter(key, value, annotation.required(), requestValues)); } } @@ -81,7 +81,10 @@ public boolean resolve( } private void addUriParameter( - String name, @Nullable Object value, boolean required, HttpRequestValues.Builder requestValues) { + Object name, @Nullable Object value, boolean required, HttpRequestValues.Builder requestValues) { + + String stringName = this.conversionService.convert(name, String.class); + Assert.notNull(stringName, "Missing path variable name"); if (value instanceof Optional) { value = ((Optional) value).orElse(null); @@ -92,15 +95,15 @@ private void addUriParameter( } if (value == null) { - Assert.isTrue(!required, "Missing required path variable '" + name + "'"); + Assert.isTrue(!required, "Missing required path variable '" + stringName + "'"); return; } if (logger.isTraceEnabled()) { - logger.trace("Resolved path variable '" + name + "' to " + value); + logger.trace("Resolved path variable '" + stringName + "' to " + value); } - requestValues.setUriVariable(name, (String) value); + requestValues.setUriVariable(stringName, (String) value); } } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolver.java new file mode 100644 index 000000000000..98b9889d8cb4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolver.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2022 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.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.ValueConstants; + +/** + * An implementation of {@link HttpServiceArgumentResolver} that resolves + * request headers based on method arguments annotated + * with {@link RequestHeader}. {@code null} values are allowed only + * if {@link RequestHeader#required()} is {@code true}. {@code null} + * values are replaced with {@link RequestHeader#defaultValue()} if it + * is not equal to {@link ValueConstants#DEFAULT_NONE}. + * + * @author Olga Maciaszek-Sharma + * @since 6.0 + */ +public class RequestHeaderArgumentResolver implements HttpServiceArgumentResolver { + + private static final Log logger = LogFactory.getLog(RequestHeaderArgumentResolver.class); + + private final ConversionService conversionService; + + public RequestHeaderArgumentResolver(ConversionService conversionService) { + Assert.notNull(conversionService, "ConversionService is required"); + this.conversionService = conversionService; + } + + @Override + public boolean resolve(@Nullable Object argument, MethodParameter parameter, + HttpRequestValues.Builder requestValues) { + RequestHeader annotation = parameter.getParameterAnnotation(RequestHeader.class); + + if (annotation == null) { + return false; + } + + if (Map.class.isAssignableFrom(parameter.getParameterType())) { + if (argument != null) { + Assert.isInstanceOf(Map.class, argument); + ((Map) argument).forEach((key, value) -> + addRequestHeader(key, value, annotation.required(), annotation.defaultValue(), + requestValues)); + } + } + else { + String name = StringUtils.hasText(annotation.value()) ? + annotation.value() : annotation.name(); + name = StringUtils.hasText(name) ? name : parameter.getParameterName(); + Assert.notNull(name, "Failed to determine request header name for parameter: " + parameter); + addRequestHeader(name, argument, annotation.required(), annotation.defaultValue(), + requestValues); + } + return true; + } + + private void addRequestHeader( + Object name, @Nullable Object value, boolean required, String defaultValue, + HttpRequestValues.Builder requestValues) { + + String stringName = this.conversionService.convert(name, String.class); + Assert.notNull(stringName, "Failed to convert request header name '" + + name + "' to String"); + + if (value instanceof Optional) { + value = ((Optional) value).orElse(null); + } + + if (value == null) { + if (!ValueConstants.DEFAULT_NONE.equals(defaultValue)) { + value = defaultValue; + } + else { + Assert.isTrue(!required, "Missing required request header '" + stringName + "'"); + return; + } + } + + String[] headerValues = toStringArray(value); + + if (logger.isTraceEnabled()) { + logger.trace("Resolved request header '" + stringName + "' to list of values: " + + String.join(", ", headerValues)); + } + + requestValues.addHeader(stringName, headerValues); + } + + private String[] toStringArray(Object value) { + return toValueStream(value) + .filter(Objects::nonNull) + .map(headerElement -> headerElement instanceof String + ? (String) headerElement : + this.conversionService.convert(headerElement, String.class)) + .filter(Objects::nonNull) + .toArray(String[]::new); + } + + private Stream toValueStream(Object value) { + if (value instanceof Object[]) { + return Arrays.stream((Object[]) value); + } + if (value instanceof Collection) { + return ((Collection) value).stream(); + } + return Stream.of(value); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/PathVariableArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/PathVariableArgumentResolverTests.java index 8db7873a5535..d59deba6e7f0 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/PathVariableArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/PathVariableArgumentResolverTests.java @@ -135,6 +135,12 @@ void shouldThrowExceptionForEmptyOptionalMapValue() { .isThrownBy(() -> this.service.executeOptionalValueMap(Map.of("id", Optional.empty()))); } + @Test + void shouldResolvePathVariableNameFromObjectMapKey() { + this.service.executeValueMapWithObjectKey(Map.of(Boolean.TRUE, "true")); + assertPathVariable("true", "true"); + } + @SuppressWarnings("SameParameterValue") private void assertPathVariable(String name, @Nullable String expectedValue) { assertThat(getActualUriVariables().get(name)).isEqualTo(expectedValue); @@ -178,6 +184,9 @@ private interface Service { @GetExchange void executeValueMap(@Nullable @PathVariable Map map); + @GetExchange + void executeValueMapWithObjectKey(@Nullable @PathVariable Map map); + @GetExchange void executeOptionalValueMap(@PathVariable Map> map); } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java new file mode 100644 index 000000000000..d512b0c74ae7 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java @@ -0,0 +1,198 @@ +/* + * Copyright 2002-2022 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.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.groovy.util.Maps; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link RequestHeaderArgumentResolver}. + * + * @author Olga Maciaszek-Sharma + */ +class RequestHeaderArgumentResolverTests { + + private final TestHttpClientAdapter clientAdapter = new TestHttpClientAdapter(); + + private final Service service = this.clientAdapter.createService(Service.class); + + @Test + void shouldResolveSingleValueRequestHeader() { + this.service.executeString("test"); + assertRequestHeaders("id", "test"); + } + + @Test + void shouldResolveRequestHeaderWithNameFromAnnotationName() { + this.service.executeNamed("test"); + assertRequestHeaders("id", "test"); + } + + @Test + void shouldResolveRequestHeaderNameFromValue() { + this.service.executeNamedWithValue("test"); + assertRequestHeaders("test", "test"); + } + + @Test + void shouldResolveObjectValueRequestHeader() { + this.service.execute(Boolean.TRUE); + assertRequestHeaders("id", "true"); + } + + @Test + void shouldResolveListRequestHeader() { + this.service.execute(List.of("test1", Boolean.TRUE, "test3")); + assertRequestHeaders("id", "test1", "true", "test3"); + } + + @Test + void shouldResolveArrayRequestHeader() { + this.service.execute("test1", Boolean.FALSE, "test3"); + assertRequestHeaders("id", "test1", "false", "test3"); + } + + @Test + void shouldResolveRequestHeadersFromMap() { + this.service.executeMap(Maps.of(Boolean.TRUE, "true", Boolean.FALSE, "false")); + assertRequestHeaders("true", "true"); + assertRequestHeaders("false", "false"); + } + + @Test + void shouldThrowExceptionWhenRequiredHeaderNull() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.service.executeString(null)); + } + + @Test + void shouldIgnoreNullWhenHeaderNotRequired() { + this.service.executeNotRequired(null); + assertThat(getActualHeaders().get("id")).isNull(); + } + + @Test + void shouldIgnoreNullMapValue() { + this.service.executeMap(null); + assertThat(getActualHeaders()).isEmpty(); + } + + @Test + void shouldResolveRequestHeaderFromOptionalArgumentWithConversion() { + this.service.executeOptional(Optional.of(Boolean.TRUE)); + assertRequestHeaders("id", "true"); + } + + @Test + void shouldResolveRequestHeaderFromOptionalArgument() { + this.service.executeOptional(Optional.of("test")); + assertRequestHeaders("id", "test"); + } + + @Test + void shouldThrowExceptionForEmptyOptional() { + assertThatIllegalArgumentException().isThrownBy(() -> this.service.execute(Optional.empty())); + } + + @Test + void shouldIgnoreEmptyOptionalWhenNotRequired() { + this.service.executeOptionalNotRequired(Optional.empty()); + assertThat(getActualHeaders().get("id")).isNull(); + } + + @Test + void shouldResolveRequestHeaderFromOptionalMapValue() { + this.service.executeOptionalMapValue(Map.of("id", Optional.of("test"))); + assertRequestHeaders("id", "test"); + } + + @Test + void shouldReplaceNullValueWithDefaultWhenAvailable() { + this.service.executeWithDefaultValue(null); + assertRequestHeaders("id", "default"); + } + + @Test + void shouldReplaceEmptyOptionalValueWithDefaultWhenAvailable() { + this.service.executeOptionalWithDefaultValue(Optional.empty()); + assertRequestHeaders("id", "default"); + } + + private void assertRequestHeaders(String key, String... values) { + assertThat(getActualHeaders().get(key)).containsOnly(values); + } + + private HttpHeaders getActualHeaders() { + return this.clientAdapter.getRequestValues().getHeaders(); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private interface Service { + + @GetExchange + void executeString(@Nullable @RequestHeader String id); + + @GetExchange + void executeNotRequired(@Nullable @RequestHeader(required = false) String id); + + @GetExchange + void execute(@RequestHeader Object id); + + @GetExchange + void execute(@RequestHeader List id); + + @GetExchange + void execute(@RequestHeader Object... id); + + @GetExchange + void executeMap(@Nullable @RequestHeader Map id); + + @GetExchange + void executeOptionalMapValue(@RequestHeader Map> id); + + @GetExchange + void executeOptional(@RequestHeader Optional id); + + @GetExchange + void executeOptionalNotRequired(@RequestHeader(required = false) Optional id); + + @GetExchange + void executeNamedWithValue(@Nullable @RequestHeader(name = "id", value = "test") String employeeId); + + @GetExchange + void executeNamed(@RequestHeader(name = "id") String employeeId); + + @GetExchange + void executeWithDefaultValue(@Nullable @RequestHeader(defaultValue = "default") String id); + + @GetExchange + void executeOptionalWithDefaultValue(@Nullable @RequestHeader(defaultValue = "default") Optional id); + } + +}