diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 54e07c9eca9b..b009471a26ff 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -99,6 +99,12 @@ public class HttpHeaders implements MultiValueMap, Serializable * @see Section 5.3.5 of RFC 7231 */ public static final String ACCEPT_LANGUAGE = "Accept-Language"; + /** + * The HTTP {@code Accept-Patch} header field name. + * @since 5.3.6 + * @see Section 3.1 of RFC 5789 + */ + public static final String ACCEPT_PATCH = "Accept-Patch"; /** * The HTTP {@code Accept-Ranges} header field name. * @see Section 5.3.5 of RFC 7233 @@ -525,6 +531,25 @@ public List getAcceptLanguageAsLocales() { .collect(Collectors.toList()); } + /** + * Set the list of acceptable {@linkplain MediaType media types} for + * {@code PATCH} methods, as specified by the {@code Accept-Patch} header. + * @since 5.3.6 + */ + public void setAcceptPatch(List mediaTypes) { + set(ACCEPT_PATCH, MediaType.toString(mediaTypes)); + } + + /** + * Return the list of acceptable {@linkplain MediaType media types} for + * {@code PATCH} methods, as specified by the {@code Accept-Patch} header. + *

Returns an empty list when the acceptable media types are unspecified. + * @since 5.3.6 + */ + public List getAcceptPatch() { + return MediaType.parseMediaTypes(get(ACCEPT_PATCH)); + } + /** * Set the (new) value of the {@code Access-Control-Allow-Credentials} response header. */ diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java index 8bb0bd16b1dd..23fe152e8608 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 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. @@ -37,6 +37,7 @@ import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.result.condition.NameValueExpression; @@ -173,7 +174,8 @@ protected HandlerMethod handleNoMatch(Set infos, String httpMethod = request.getMethodValue(); Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.matches(httpMethod)) { - HttpOptionsHandler handler = new HttpOptionsHandler(methods); + Set mediaTypes = helper.getConsumablePatchMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } throw new MethodNotAllowedException(httpMethod, methods); @@ -301,6 +303,22 @@ public List>> getParamConditions() { collect(Collectors.toList()); } + /** + * Return declared "consumable" types but only among those that have + * PATCH specified, or that have no methods at all. + */ + public Set getConsumablePatchMediaTypes() { + Set result = new LinkedHashSet<>(); + for (PartialMatch match : this.partialMatches) { + Set methods = match.getInfo().getMethodsCondition().getMethods(); + if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) { + result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); + } + } + return result; + } + + /** * Container for a RequestMappingInfo that matches the URL path at least. @@ -367,8 +385,9 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); + this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); } private static Set initAllowedHttpMethods(Set declaredMethods) { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java index dc505f80f3b9..79b6813bde67 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 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. @@ -37,6 +37,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; import org.springframework.util.MultiValueMap; @@ -44,6 +45,7 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.BindingContext; import org.springframework.web.reactive.HandlerMapping; @@ -192,10 +194,12 @@ public void getHandlerHttpOptions() { List allMethodExceptTrace = new ArrayList<>(Arrays.asList(HttpMethod.values())); allMethodExceptTrace.remove(HttpMethod.TRACE); - testHttpOptions("/foo", EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS)); - testHttpOptions("/person/1", EnumSet.of(HttpMethod.PUT, HttpMethod.OPTIONS)); - testHttpOptions("/persons", EnumSet.copyOf(allMethodExceptTrace)); - testHttpOptions("/something", EnumSet.of(HttpMethod.PUT, HttpMethod.POST)); + testHttpOptions("/foo", EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS), null); + testHttpOptions("/person/1", EnumSet.of(HttpMethod.PUT, HttpMethod.OPTIONS), null); + testHttpOptions("/persons", EnumSet.copyOf(allMethodExceptTrace), null); + testHttpOptions("/something", EnumSet.of(HttpMethod.PUT, HttpMethod.POST), null); + testHttpOptions("/qux", EnumSet.of(HttpMethod.PATCH,HttpMethod.GET,HttpMethod.HEAD,HttpMethod.OPTIONS), + new MediaType("foo", "bar")); } @Test @@ -332,7 +336,7 @@ private void testHttpMediaTypeNotSupportedException(String url) { assertError(mono, UnsupportedMediaTypeStatusException.class, ex -> assertThat(ex.getSupportedMediaTypes()).as("Invalid supported consumable media types").isEqualTo(Collections.singletonList(new MediaType("application", "xml")))); } - private void testHttpOptions(String requestURI, Set allowedMethods) { + private void testHttpOptions(String requestURI, Set allowedMethods, @Nullable MediaType acceptPatch) { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI)); HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); @@ -346,7 +350,13 @@ private void testHttpOptions(String requestURI, Set allowedMethods) Object value = result.getReturnValue(); assertThat(value).isNotNull(); assertThat(value.getClass()).isEqualTo(HttpHeaders.class); - assertThat(((HttpHeaders) value).getAllow()).isEqualTo(allowedMethods); + + HttpHeaders headers = (HttpHeaders) value; + assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); + + if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { + assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); + } } private void testMediaTypeNotAcceptable(String url) { @@ -430,6 +440,16 @@ public HttpHeaders fooOptions() { return headers; } + @RequestMapping(value = "/qux", method = RequestMethod.GET, produces = "application/xml") + public String getBaz() { + return ""; + } + + @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") + public void patchBaz(String value) { + } + + public void dummy() { } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java index a52e6c7db4ba..254deb91282f 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -244,7 +244,8 @@ protected HandlerMethod handleNoMatch( if (helper.hasMethodsMismatch()) { Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.matches(request.getMethod())) { - HttpOptionsHandler handler = new HttpOptionsHandler(methods); + Set mediaTypes = helper.getConsumablePatchMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } throw new HttpRequestMethodNotSupportedException(request.getMethod(), methods); @@ -411,6 +412,21 @@ public List getParamConditions() { return result; } + /** + * Return declared "consumable" types but only among those that have + * PATCH specified, or that have no methods at all. + */ + public Set getConsumablePatchMediaTypes() { + Set result = new LinkedHashSet<>(); + for (PartialMatch match : this.partialMatches) { + Set methods = match.getInfo().getMethodsCondition().getMethods(); + if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) { + result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); + } + } + return result; + } + /** * Container for a RequestMappingInfo that matches the URL path at least. @@ -475,8 +491,9 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); + this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); } private static Set initAllowedHttpMethods(Set declaredMethods) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java index 8b8afa81c9fc..32277c53bee5 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -22,6 +22,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; @@ -31,8 +32,10 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.server.RequestPath; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; import org.springframework.util.MultiValueMap; import org.springframework.web.HttpMediaTypeNotAcceptableException; @@ -177,10 +180,11 @@ void getHandlerMediaTypeNotSupported(TestRequestMappingInfoHandlerMapping mappin @PathPatternsParameterizedTest void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception { - testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS"); - testHttpOptions(mapping, "/person/1", "PUT,OPTIONS"); - testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); - testHttpOptions(mapping, "/something", "PUT,POST"); + testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS", null); + testHttpOptions(mapping, "/person/1", "PUT,OPTIONS", null); + testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", null); + testHttpOptions(mapping, "/something", "PUT,POST", null); + testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar")); } @PathPatternsParameterizedTest @@ -401,8 +405,8 @@ private void testHttpMediaTypeNotSupportedException(TestRequestMappingInfoHandle .satisfies(ex -> assertThat(ex.getSupportedMediaTypes()).containsExactly(MediaType.APPLICATION_XML)); } - private void testHttpOptions( - TestRequestMappingInfoHandlerMapping mapping, String requestURI, String allowHeader) throws Exception { + private void testHttpOptions(TestRequestMappingInfoHandlerMapping mapping, String requestURI, + String allowHeader, @Nullable MediaType acceptPatch) throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", requestURI); HandlerMethod handlerMethod = getHandler(mapping, request); @@ -413,7 +417,15 @@ private void testHttpOptions( assertThat(result).isNotNull(); assertThat(result.getClass()).isEqualTo(HttpHeaders.class); - assertThat(((HttpHeaders) result).getFirst("Allow")).isEqualTo(allowHeader); + HttpHeaders headers = (HttpHeaders) result; + Set allowedMethods = Arrays.stream(allowHeader.split(",")) + .map(HttpMethod::valueOf) + .collect(Collectors.toSet()); + assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); + + if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { + assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); + } } private void testHttpMediaTypeNotAcceptableException(TestRequestMappingInfoHandlerMapping mapping, String url) { @@ -502,6 +514,15 @@ public HttpHeaders fooOptions() { headers.add("Allow", "PUT,POST"); return headers; } + + @RequestMapping(value = "/qux", method = RequestMethod.GET, produces = "application/xml") + public String getBaz() { + return ""; + } + + @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") + public void patchBaz(String value) { + } }