Skip to content

Add meta-annotations support for @RequestHeader in spring-web, and spring-webflux #35219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,61 @@ TIP: Built-in support is available for converting a comma-separated string into
array or collection of strings or other types known to the type conversion system. For
example, a method parameter annotated with `@RequestHeader("Accept")` may be of type
`String` but also of `String[]` or `List<String>`.

We can use `@RequestHeader` as a meta-annotation, so you can create custom annotations for repeatedly used headers, like the `Accept-Language` header.
The next example demonstrates how we could do this with an annotation named `@LanguageRequestHeader`:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, defaultValue = "en")
public @interface LanguageRequestHeader {}
----

Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, defaultValue = "en")
annotation class LanguageRequestHeader
----
======

Now that `@LanguageRequestHeader` has been specified, we can use it to signal Spring to resolve our `@LanguageRequestHeader` from the current `Accept-Language` request header:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@GetMapping("/product")
public Flux<Product> fetchProduct(@LanguageRequestHeader String lang) {

// .. fetch product and return them ...
}
----

Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
@GetMapping("/product")
fun fetchProduct(@LanguageRequestHeader lang: String): Flux<Product> {

// .. fetch product and return them ...
}
----
======


TIP: We can replace the lang parameter type from `String` to `java.util.Locale` or to your own Language enum, and Spring will handle the conversion of the header value. You may need to implement the xref:core/validation/convert.adoc[`Converter`] interface and register it.
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,62 @@ TIP: Built-in support is available for converting a comma-separated string into
array or collection of strings or other types known to the type conversion system. For
example, a method parameter annotated with `@RequestHeader("Accept")` can be of type
`String` but also `String[]` or `List<String>`.


We can use `@RequestHeader` as a meta-annotation, so you can create custom annotations for repeatedly used headers, like the `Accept-Language` header.
The next example demonstrates how we could do this with an annotation named `@LanguageRequestHeader`:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, defaultValue = "en")
public @interface LanguageRequestHeader {}
----

Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, defaultValue = "en")
annotation class LanguageRequestHeader
----
======

Now that `@LanguageRequestHeader` has been specified, we can use it to signal Spring to resolve our `@LanguageRequestHeader` from the current `Accept-Language` request header:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@GetMapping("/product")
public List<Product> fetchProduct(@LanguageRequestHeader String lang) {

// .. fetch product and return them ...
}
----

Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
@GetMapping("/product")
fun fetchProduct(@LanguageRequestHeader lang: String): List<Product> {

// .. fetch product and return them ...
}
----
======


TIP: We can replace the lang parameter type from `String` to `java.util.Locale` or to your own Language enum, and Spring will handle the conversion of the header value. You may need to implement the xref:core/validation/convert.adoc[`Converter`] interface and register it.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
import kotlin.reflect.jvm.ReflectJvmMapping;
import org.jspecify.annotations.Nullable;

import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
Expand Down Expand Up @@ -650,6 +652,27 @@ public boolean hasParameterAnnotations() {
return null;
}

/**
* Return the parameter annotation of the given type, if available,
* either directly declared or as a meta-annotation.
* @param annotationType the annotation type to look for
* @return the annotation object, or {@code null} if not found
*/
public <A extends Annotation> @Nullable A getParameterNestedAnnotation(Class<A> annotationType) {
A annotation = getParameterAnnotation(annotationType);
if (annotation != null) {
return annotation;
}
Annotation[] annotationsToSearch = getParameterAnnotations();
for (Annotation toSearch : annotationsToSearch) {
annotation = AnnotationUtils.findAnnotation(toSearch.annotationType(), annotationType);
if (annotation != null) {
return MergedAnnotations.from(toSearch).get(annotationType).synthesize();
}
}
return null;
}

/**
* Return whether the parameter is declared with the given annotation type.
* @param annotationType the annotation type to look for
Expand All @@ -659,6 +682,16 @@ public <A extends Annotation> boolean hasParameterAnnotation(Class<A> annotation
return (getParameterAnnotation(annotationType) != null);
}

/**
* Return whether the parameter is declared with the given annotation type,
* either directly or as a meta-annotation.
* @param annotationType the annotation type to look for
* @see #getParameterNestedAnnotation(Class)
*/
public <A extends Annotation> boolean hasParameterNestedAnnotation(Class<A> annotationType) {
return getParameterNestedAnnotation(annotationType) != null;
}

/**
* Initialize parameter name discovery for this method parameter.
* <p>This method does not actually try to retrieve the parameter name at
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
/**
* Annotation which indicates that a method parameter should be bound to a web request header.
*
* <p>Supported for annotated handler methods in Spring MVC and Spring WebFlux.
* <p>Supported for declared as a meta-annotation or directly annotated handler methods
* in Spring MVC and Spring WebFlux.
*
* <p>If the method parameter is {@link java.util.Map Map&lt;String, String&gt;},
* {@link org.springframework.util.MultiValueMap MultiValueMap&lt;String, String&gt;},
Expand All @@ -41,7 +42,7 @@
* @see RequestParam
* @see CookieValue
*/
@Target(ElementType.PARAMETER)
@Target({ ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestHeader {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public void visitResults(Visitor visitor) {
}
continue;
}
RequestHeader requestHeader = param.getParameterAnnotation(RequestHeader.class);
RequestHeader requestHeader = param.getParameterNestedAnnotation(RequestHeader.class);
if (requestHeader != null) {
visitor.requestHeader(requestHeader, result);
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ public RequestHeaderMethodArgumentResolver(@Nullable ConfigurableBeanFactory bea

@Override
public boolean supportsParameter(MethodParameter parameter) {
return (parameter.hasParameterAnnotation(RequestHeader.class) &&
return (parameter.hasParameterNestedAnnotation(RequestHeader.class) &&
!Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) &&
!HttpHeaders.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType());
}

@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
RequestHeader ann = parameter.getParameterAnnotation(RequestHeader.class);
RequestHeader ann = parameter.getParameterNestedAnnotation(RequestHeader.class);
Assert.state(ann != null, "No RequestHeader annotation");
return new RequestHeaderNamedValueInfo(ann);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public RequestHeaderArgumentResolver(ConversionService conversionService) {

@Override
protected @Nullable NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
RequestHeader annot = parameter.getParameterAnnotation(RequestHeader.class);
RequestHeader annot = parameter.getParameterNestedAnnotation(RequestHeader.class);
return (annot == null ? null :
new NamedValueInfo(annot.name(), annot.required(), annot.defaultValue(), "request header", true));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

package org.springframework.web.method.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
Expand Down Expand Up @@ -69,6 +73,7 @@ class RequestHeaderMethodArgumentResolverTests {
private MethodParameter paramUuid;
private MethodParameter paramUuidOptional;
private MethodParameter paramUuidPlaceholder;
private MethodParameter paramNestedAnnotation;

private MockHttpServletRequest servletRequest;

Expand All @@ -94,6 +99,7 @@ void setup() throws Exception {
paramUuid = new SynthesizingMethodParameter(method, 9);
paramUuidOptional = new SynthesizingMethodParameter(method, 10);
paramUuidPlaceholder = new SynthesizingMethodParameter(method, 11);
paramNestedAnnotation = new SynthesizingMethodParameter(method, 12);

servletRequest = new MockHttpServletRequest();
webRequest = new ServletWebRequest(servletRequest, new MockHttpServletResponse());
Expand All @@ -113,6 +119,8 @@ void supportsParameter() {
assertThat(resolver.supportsParameter(paramNamedDefaultValueStringHeader)).as("String parameter not supported").isTrue();
assertThat(resolver.supportsParameter(paramNamedValueStringArray)).as("String array parameter not supported").isTrue();
assertThat(resolver.supportsParameter(paramNamedValueMap)).as("non-@RequestParam parameter supported").isFalse();
assertThat(resolver.supportsParameter(paramNestedAnnotation)).as("String parameter with nested annotation not supported").isTrue();

}

@Test
Expand Down Expand Up @@ -332,6 +340,16 @@ public void uuidPlaceholderConversionWithEmptyValue() {
}
}

@Test
void resolveStringNestedAnnotationArgument() throws Exception {
String expected = "foo";
servletRequest.addHeader("name", expected);

Object result = resolver.resolveArgument(paramNestedAnnotation, null, webRequest, null);

assertThat(result).isEqualTo(expected);
}

void params(
@RequestHeader(name = "name", defaultValue = "bar") String param1,
@RequestHeader("name") String[] param2,
Expand All @@ -344,7 +362,15 @@ void params(
@RequestHeader("name") Instant instantParam,
@RequestHeader("name") UUID uuid,
@RequestHeader(name = "name", required = false) UUID uuidOptional,
@RequestHeader(name = "${systemProperty}") UUID uuidPlaceholder) {
@RequestHeader(name = "${systemProperty}") UUID uuidPlaceholder,
@NameRequestHeader String param7) {
}


@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@RequestHeader(name = "name", defaultValue = "bar")
private @interface NameRequestHeader {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
package org.springframework.web.method.support;

import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
Expand Down Expand Up @@ -68,7 +72,7 @@ class HandlerMethodValidationExceptionTests {


private final HandlerMethod handlerMethod = handlerMethod(new ValidController(),
controller -> controller.handle(person, person, person, List.of(), person, "", "", "", "", "", ""));
controller -> controller.handle(person, person, person, List.of(), person, "", "", "", "", "", "", ""));

private final TestVisitor visitor = new TestVisitor();

Expand All @@ -87,7 +91,7 @@ void traverse() {
@ModelAttribute: modelAttribute1, @ModelAttribute: modelAttribute2, \
@RequestBody: requestBody, @RequestBody: requestBodyList, @RequestPart: requestPart, \
@RequestParam: requestParam1, @RequestParam: requestParam2, \
@RequestHeader: header, @PathVariable: pathVariable, \
@RequestHeader: header, @RequestHeader: nestedAnnotationHeader, @PathVariable: pathVariable, \
@CookieValue: cookie, @MatrixVariable: matrixVariable""");
}

Expand All @@ -103,7 +107,7 @@ void traverseRemaining() {
Other: modelAttribute1, @ModelAttribute: modelAttribute2, \
@RequestBody: requestBody, @RequestBody: requestBodyList, @RequestPart: requestPart, \
Other: requestParam1, @RequestParam: requestParam2, \
@RequestHeader: header, @PathVariable: pathVariable, \
@RequestHeader: header, @RequestHeader: nestedAnnotationHeader, @PathVariable: pathVariable, \
@CookieValue: cookie, @MatrixVariable: matrixVariable""");
}

Expand Down Expand Up @@ -137,7 +141,6 @@ private static MethodValidationResult createMethodValidationResult(HandlerMethod
}



@SuppressWarnings("unused")
private record Person(@Size(min = 1, max = 10) String name) {

Expand All @@ -161,11 +164,17 @@ void handle(
@Size(min = 5) String requestParam1,
@Size(min = 5) @RequestParam String requestParam2,
@Size(min = 5) @RequestHeader String header,
@Size(min = 5) @HeaderRequestHeader String nestedAnnotationHeader,
@Size(min = 5) @PathVariable String pathVariable,
@Size(min = 5) @CookieValue String cookie,
@Size(min = 5) @MatrixVariable String matrixVariable) {
}

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@RequestHeader(name = "header")
private @interface HeaderRequestHeader {
}
}


Expand Down
Loading