diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java index ef10b9822114..5ef2b8f2f26f 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java @@ -118,7 +118,7 @@ void options() { RestTestClientTests.this.client.options().uri("/test") .exchange() .expectStatus().isOk() - .expectHeader().valueEquals("Allow", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS") + .expectHeader().valueEquals("Allow", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY") .expectBody().isEmpty(); } 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 12216b83d5a1..bcedd663b453 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -124,6 +124,12 @@ public class HttpHeaders implements Serializable { * @see Section 3.1 of RFC 5789 */ public static final String ACCEPT_PATCH = "Accept-Patch"; + /** + * The HTTP {@code Accept-Query} header field name. + * @since 7.1 + * @see Section 3 of RFC 10008 + */ + public static final String ACCEPT_QUERY = "Accept-Query"; /** * The HTTP {@code Accept-Ranges} header field name. * @see Section 5.3.5 of RFC 7233 @@ -648,6 +654,27 @@ public List getAcceptPatch() { return MediaType.parseMediaTypes(get(ACCEPT_PATCH)); } + /** + * Set the list of acceptable {@linkplain MediaType media types} for + * {@code QUERY} methods, as specified by the {@code Accept-Query} header. + * @since 7.1 + */ + public void setAcceptQuery(List mediaTypes) { + set(ACCEPT_QUERY, MediaType.toString(mediaTypes)); + } + + /** + * Return the list of acceptable {@linkplain MediaType media types} for + * {@code QUERY} methods, as specified by the {@code Accept-Query} header. + *

Returns an empty list when the acceptable media types are unspecified. + * @since 7.1 + */ + public List getAcceptQuery() { + return MediaType.parseMediaTypes(get(ACCEPT_QUERY)); + } + + + /** * Set the (new) value of the {@code Access-Control-Allow-Credentials} response header. */ diff --git a/spring-web/src/main/java/org/springframework/http/HttpMethod.java b/spring-web/src/main/java/org/springframework/http/HttpMethod.java index 422abf282031..4c8c02791ad3 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpMethod.java +++ b/spring-web/src/main/java/org/springframework/http/HttpMethod.java @@ -39,25 +39,25 @@ public final class HttpMethod implements Comparable, Serializable { /** * The HTTP method {@code GET}. - * @see HTTP 1.1, section 9.3 + * @see HTTP Semantics, section 9.3.1 */ public static final HttpMethod GET = new HttpMethod("GET"); /** * The HTTP method {@code HEAD}. - * @see HTTP 1.1, section 9.4 + * @see HTTP Semantics, section 9.3.2 */ public static final HttpMethod HEAD = new HttpMethod("HEAD"); /** * The HTTP method {@code POST}. - * @see HTTP 1.1, section 9.5 + * @see HTTP Semantics, section 9.3.3 */ public static final HttpMethod POST = new HttpMethod("POST"); /** * The HTTP method {@code PUT}. - * @see HTTP 1.1, section 9.6 + * @see HTTP Semantics, section 9.3.4 */ public static final HttpMethod PUT = new HttpMethod("PUT"); @@ -69,23 +69,30 @@ public final class HttpMethod implements Comparable, Serializable { /** * The HTTP method {@code DELETE}. - * @see HTTP 1.1, section 9.7 + * @see HTTP Semantics, section 9.3.5 */ public static final HttpMethod DELETE = new HttpMethod("DELETE"); /** * The HTTP method {@code OPTIONS}. - * @see HTTP 1.1, section 9.2 + * @see HTTP Semantics, section 9.3.7 */ public static final HttpMethod OPTIONS = new HttpMethod("OPTIONS"); /** * The HTTP method {@code TRACE}. - * @see HTTP 1.1, section 9.8 + * @see HTTP Semantics, section 9.3.8 */ public static final HttpMethod TRACE = new HttpMethod("TRACE"); - private static final HttpMethod[] values = new HttpMethod[] { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE }; + /** + * The HTTP method {@code QUERY}. + * @since 7.1 + * @see The HTTP QUERY Method, section 2 + */ + public static final HttpMethod QUERY = new HttpMethod("QUERY"); + + private static final HttpMethod[] values = new HttpMethod[] { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, QUERY }; private final String name; @@ -99,7 +106,7 @@ private HttpMethod(String name) { * Returns an array containing the standard HTTP methods. Specifically, * this method returns an array containing {@link #GET}, {@link #HEAD}, * {@link #POST}, {@link #PUT}, {@link #PATCH}, {@link #DELETE}, - * {@link #OPTIONS}, and {@link #TRACE}. + * {@link #OPTIONS}, {@link #TRACE}, and {@link #QUERY}. * *

Note that the returned value does not include any HTTP methods defined * in WebDav. @@ -137,6 +144,7 @@ public static HttpMethod valueOf(String method) { case "DELETE" -> DELETE; case "OPTIONS" -> OPTIONS; case "TRACE" -> TRACE; + case "QUERY" -> QUERY; default -> new HttpMethod(method); }; } diff --git a/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java index cc7ebcc776c9..826a016f7de4 100644 --- a/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java @@ -32,6 +32,7 @@ import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpPut; import org.apache.hc.client5.http.classic.methods.HttpTrace; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; import org.apache.hc.client5.http.config.Configurable; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -300,6 +301,9 @@ else if (HttpMethod.OPTIONS.equals(httpMethod)) { else if (HttpMethod.TRACE.equals(httpMethod)) { return new HttpTrace(uri); } + else if (HttpMethod.QUERY.equals(httpMethod)) { + return new HttpUriRequestBase(HttpMethod.QUERY.name(), uri); + } throw new IllegalArgumentException("Invalid HTTP method: " + httpMethod); } diff --git a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java index 89428c5221fb..52991fce189e 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java @@ -132,6 +132,9 @@ public HttpHeaders getHeaders() { if (HttpMethod.PATCH.equals(this.httpMethod)) { headers.setAcceptPatch(getSupportedMediaTypes()); } + else if (HttpMethod.QUERY.equals(this.httpMethod)) { + headers.setAcceptQuery(getSupportedMediaTypes()); + } return headers; } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMethod.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMethod.java index c4d353612080..5e38f7b6b679 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMethod.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMethod.java @@ -26,7 +26,7 @@ * {@link RequestMapping#method()} attribute of the {@link RequestMapping} annotation. * *

Note that, by default, {@link org.springframework.web.servlet.DispatcherServlet} - * supports GET, HEAD, POST, PUT, PATCH, and DELETE only. DispatcherServlet will + * supports GET, QUERY, HEAD, POST, PUT, PATCH, and DELETE only. DispatcherServlet will * process TRACE and OPTIONS with the default HttpServlet behavior unless explicitly * told to dispatch those request types as well: Check out the "dispatchOptionsRequest" * and "dispatchTraceRequest" properties, switching them to "true" if necessary. @@ -39,7 +39,7 @@ */ public enum RequestMethod { - GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE; + GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, QUERY; /** @@ -60,6 +60,7 @@ public enum RequestMethod { case "DELETE" -> DELETE; case "OPTIONS" -> OPTIONS; case "TRACE" -> TRACE; + case "QUERY" -> QUERY; default -> null; }; } @@ -92,6 +93,7 @@ public HttpMethod asHttpMethod() { case DELETE -> HttpMethod.DELETE; case OPTIONS -> HttpMethod.OPTIONS; case TRACE -> HttpMethod.TRACE; + case QUERY -> HttpMethod.QUERY; }; } diff --git a/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java b/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java index a9a1d2024278..09a180805b14 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java @@ -47,7 +47,7 @@ import org.springframework.util.StringUtils; /** - * {@code Filter} that parses form data for HTTP PUT, PATCH, and DELETE requests + * {@code Filter} that parses form data for HTTP PUT, PATCH, DELETE, and QUERY requests * and exposes it as Servlet request parameters. By default, the Servlet spec * only requires this for HTTP POST. * @@ -56,7 +56,7 @@ */ public class FormContentFilter extends OncePerRequestFilter { - private static final List HTTP_METHODS = Arrays.asList("PUT", "PATCH", "DELETE"); + private static final List HTTP_METHODS = Arrays.asList("PUT", "PATCH", "DELETE", "QUERY"); private FormHttpMessageConverter formConverter = new FormHttpMessageConverter(); diff --git a/spring-web/src/main/java/org/springframework/web/filter/HiddenHttpMethodFilter.java b/spring-web/src/main/java/org/springframework/web/filter/HiddenHttpMethodFilter.java index 68c703f05bb4..3654673b0887 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/HiddenHttpMethodFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/HiddenHttpMethodFilter.java @@ -38,7 +38,7 @@ * is to use a normal POST with an additional hidden form field ({@code _method}) * to pass the "real" HTTP method along. This filter reads that parameter and changes * the {@link HttpServletRequestWrapper#getMethod()} return value accordingly. - * Only {@code "PUT"}, {@code "DELETE"} and {@code "PATCH"} HTTP methods are allowed. + * Only {@code "PUT"}, {@code "DELETE"}, {@code "PATCH"}, and {@code "QUERY"} HTTP methods are allowed. * *

The name of the request parameter defaults to {@code _method}, but can be * adapted via the {@link #setMethodParam(String) methodParam} property. @@ -55,7 +55,7 @@ public class HiddenHttpMethodFilter extends OncePerRequestFilter { private static final List ALLOWED_METHODS = - List.of(HttpMethod.PUT.name(), HttpMethod.DELETE.name(), HttpMethod.PATCH.name()); + List.of(HttpMethod.PUT.name(), HttpMethod.DELETE.name(), HttpMethod.PATCH.name(), HttpMethod.QUERY.name()); /** Default method parameter: {@code _method}. */ public static final String DEFAULT_METHOD_PARAM = "_method"; diff --git a/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java b/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java index aef52469b949..7fc0b2167fc5 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.java @@ -47,7 +47,7 @@ public class HiddenHttpMethodFilter implements WebFilter { private static final List ALLOWED_METHODS = - List.of(HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH); + List.of(HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH, HttpMethod.QUERY); /** Default name of the form parameter with the HTTP method to use. */ public static final String DEFAULT_METHOD_PARAMETER_NAME = "_method"; diff --git a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java index dc5dbfad2b85..6b5124e76454 100644 --- a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java @@ -161,6 +161,9 @@ public HttpHeaders getHeaders() { if (this.method == HttpMethod.PATCH) { headers.setAcceptPatch(this.supportedMediaTypes); } + else if (this.method == HttpMethod.QUERY) { + headers.setAcceptQuery(this.supportedMediaTypes); + } return headers; } diff --git a/spring-web/src/test/java/org/springframework/http/HttpMethodTests.java b/spring-web/src/test/java/org/springframework/http/HttpMethodTests.java index ac3a3f0ae4e6..a24aa6079028 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpMethodTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpMethodTests.java @@ -44,12 +44,12 @@ void comparison() { void values() { HttpMethod[] values = HttpMethod.values(); assertThat(values).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE); + HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE, HttpMethod.QUERY); // check defensive copy values[0] = HttpMethod.POST; assertThat(HttpMethod.values()).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE); + HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE, HttpMethod.QUERY); } @Test diff --git a/spring-web/src/test/java/org/springframework/web/bind/annotation/RequestMethodTests.java b/spring-web/src/test/java/org/springframework/web/bind/annotation/RequestMethodTests.java index a16fb2b84d70..a7f7fb31fc1d 100644 --- a/spring-web/src/test/java/org/springframework/web/bind/annotation/RequestMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/bind/annotation/RequestMethodTests.java @@ -29,7 +29,7 @@ class RequestMethodTests { @Test void resolveString() { - String[] methods = new String[]{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"}; + String[] methods = new String[]{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "QUERY"}; for (String httpMethod : methods) { RequestMethod requestMethod = RequestMethod.resolve(httpMethod); assertThat(requestMethod).isNotNull(); diff --git a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java index 5d33f8266b7d..08109a40ac84 100644 --- a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java @@ -75,7 +75,7 @@ void tearDown() throws Exception { private MockResponse getRequest(RecordedRequest request, byte[] body, @Nullable String contentType) { if (request.getMethod().equals("OPTIONS")) { - return new MockResponse.Builder().code(200).setHeader("Allow", "GET, OPTIONS, HEAD, TRACE").build(); + return new MockResponse.Builder().code(200).setHeader("Allow", "GET, QUERY, OPTIONS, HEAD, TRACE").build(); } Buffer buf = new Buffer(); buf.write(body); @@ -240,6 +240,29 @@ private MockResponse putRequest(RecordedRequest request, String expectedRequestC return new MockResponse.Builder().code(202).build(); } + private MockResponse queryRequest(RecordedRequest request, String expectedRequestContent, + String contentType, byte[] responseBody) { + + assertThat(request.getHeaders().values(CONTENT_LENGTH)).hasSize(1); + assertThat(Integer.parseInt(request.getHeaders().get(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); + String requestContentType = request.getHeaders().get(CONTENT_TYPE); + assertThat(requestContentType).as("No content-type").isNotNull(); + Charset charset = StandardCharsets.ISO_8859_1; + if (requestContentType.contains("charset=")) { + String charsetName = requestContentType.split("charset=")[1]; + charset = Charset.forName(charsetName); + } + assertThat(request.getBody().string(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); + Buffer buf = new Buffer(); + buf.write(responseBody); + return new MockResponse.Builder() + .code(200) + .setHeader(CONTENT_TYPE, contentType) + .setHeader(CONTENT_LENGTH, responseBody.length) + .body(buf) + .build(); + } + protected class TestDispatcher extends Dispatcher { @@ -302,6 +325,9 @@ else if (request.getTarget().equals("/patch")) { else if (request.getTarget().equals("/put")) { return putRequest(request, helloWorld); } + else if (request.getTarget().equals("/query")) { + return queryRequest(request, helloWorld, textContentType.toString(), helloWorldBytes); + } return new MockResponse.Builder().code(404).build(); } catch (Throwable ex) { diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java index 6a99e795b751..d388184c9f01 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java @@ -295,7 +295,7 @@ void optionsForAllow(ClientHttpRequestFactory clientHttpRequestFactory) { setUpClient(clientHttpRequestFactory); Set allowed = template.optionsForAllow(URI.create(baseUrl + "/get")); - assertThat(allowed).as("Invalid response").isEqualTo(Set.of(HttpMethod.GET, HttpMethod.OPTIONS, HttpMethod.HEAD, HttpMethod.TRACE)); + assertThat(allowed).as("Invalid response").isEqualTo(Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.OPTIONS, HttpMethod.HEAD, HttpMethod.TRACE)); } @ParameterizedRestTemplateTest diff --git a/spring-web/src/test/java/org/springframework/web/filter/FormContentFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/FormContentFilterTests.java index d28c1da1e9ac..f016d119d1e8 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/FormContentFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/FormContentFilterTests.java @@ -58,14 +58,14 @@ void setup() { @Test - void wrapPutPatchAndDeleteOnly() throws Exception { + void wrapPutPatchQueryAndDeleteOnly() throws Exception { for (HttpMethod method : HttpMethod.values()) { MockHttpServletRequest request = new MockHttpServletRequest(method.name(), "/"); request.setContent("foo=bar".getBytes(StandardCharsets.ISO_8859_1)); request.setContentType("application/x-www-form-urlencoded; charset=ISO-8859-1"); this.filterChain = new MockFilterChain(); this.filter.doFilter(request, this.response, this.filterChain); - if (method == HttpMethod.PUT || method == HttpMethod.PATCH || method == HttpMethod.DELETE) { + if (method == HttpMethod.PUT || method == HttpMethod.PATCH || method == HttpMethod.DELETE || method == HttpMethod.QUERY) { assertThat(this.filterChain.getRequest()).isNotSameAs(request); } else { diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/method/MvcAnnotationPredicates.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/method/MvcAnnotationPredicates.java index fb106c77c25b..83ce9de8bb18 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/method/MvcAnnotationPredicates.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/method/MvcAnnotationPredicates.java @@ -114,7 +114,6 @@ public static RequestMappingPredicate headMapping(String... path) { } - public static class ModelAttributePredicate implements Predicate { private String name; 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 5f6c24b17985..452428b250e7 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 @@ -65,9 +65,12 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe private static final Method HTTP_OPTIONS_HANDLE_METHOD; + private static final Method HTTP_HEAD_QUERY_HANDLE_METHOD; + static { try { HTTP_OPTIONS_HANDLE_METHOD = HttpOptionsHandler.class.getMethod("handle"); + HTTP_HEAD_QUERY_HANDLE_METHOD = HttpHeadQueryHandler.class.getMethod("handle"); } catch (NoSuchMethodException ex) { // Should never happen @@ -189,10 +192,16 @@ protected void handleMatch(RequestMappingInfo info, HandlerMethod handlerMethod, HttpMethod httpMethod = request.getMethod(); Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.equals(httpMethod)) { - Set mediaTypes = helper.getConsumablePatchMediaTypes(); - HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); + Set patchMediaTypes = helper.getConsumablePatchMediaTypes(); + Set queryMediaTypes = helper.getConsumableQueryMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, patchMediaTypes, queryMediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } + if (HttpMethod.HEAD.equals(httpMethod) && methods.contains(HttpMethod.QUERY)) { + Set queryMediaTypes = helper.getConsumableQueryMediaTypes(); + HttpHeadQueryHandler handler = new HttpHeadQueryHandler(methods, queryMediaTypes); + return new HandlerMethod(handler, HTTP_HEAD_QUERY_HANDLE_METHOD); + } throw new MethodNotAllowedException(httpMethod, methods); } @@ -323,14 +332,23 @@ public List>> getParamConditions() { * 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; + return getConsumableMediaTypesForMethod(RequestMethod.PATCH); + } + + /** + * Return declared "consumable" types but only among those that have + * PATCH specified, or that have no methods at all. + */ + public Set getConsumableQueryMediaTypes() { + return getConsumableMediaTypesForMethod(RequestMethod.QUERY); + } + + private Set getConsumableMediaTypesForMethod(RequestMethod method) { + return this.partialMatches.stream() + .map(PartialMatch::getInfo) + .filter(info -> info.getMethodsCondition().getMethods().isEmpty() || info.getMethodsCondition().getMethods().contains(method)) + .flatMap(info -> info.getConsumesCondition().getConsumableMediaTypes().stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); } @@ -400,9 +418,10 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch, Set acceptQuery) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); + this.headers.setAcceptQuery(new ArrayList<>(acceptQuery)); } private static Set initAllowedHttpMethods(Set declaredMethods) { @@ -413,7 +432,7 @@ private static Set initAllowedHttpMethods(Set declaredMe } else { Set result = new LinkedHashSet<>(declaredMethods); - if (result.contains(HttpMethod.GET)) { + if (result.contains(HttpMethod.GET) || result.contains(HttpMethod.QUERY)) { result.add(HttpMethod.HEAD); } result.add(HttpMethod.OPTIONS); @@ -427,4 +446,22 @@ public HttpHeaders handle() { } } + /** + * Default handler for HTTP HEAD targeting a QUERY endpoint. + */ + private static class HttpHeadQueryHandler { + + private final HttpHeaders headers = new HttpHeaders(); + + public HttpHeadQueryHandler(Set declaredMethods, Set acceptQuery) { + this.headers.setAllow(HttpOptionsHandler.initAllowedHttpMethods(declaredMethods)); + this.headers.setAcceptQuery(new ArrayList<>(acceptQuery)); + } + + @SuppressWarnings("unused") + public HttpHeaders handle() { + return this.headers; + } + } + } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java index 7d8114d50f78..9b2a7ace68ba 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java @@ -74,7 +74,7 @@ public abstract class AbstractMessageReaderArgumentResolver extends HandlerMethodArgumentResolverSupport { private static final Set SUPPORTED_METHODS = - Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); + Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.QUERY); private final List> messageReaders; 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 bd8c0e13e68f..30454eaeba60 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 @@ -195,6 +195,10 @@ void getHandlerHttpOptions() { testHttpOptions("/something", Set.of(HttpMethod.PUT, HttpMethod.POST), null); testHttpOptions("/qux", Set.of(HttpMethod.PATCH,HttpMethod.GET,HttpMethod.HEAD,HttpMethod.OPTIONS), new MediaType("foo", "bar")); + testHttpOptions("/quid", Set.of(HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS), + new MediaType("application", "json")); + testHttpHeadQuery("/quid", Set.of(HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS), + new MediaType("application", "json")); } @Test @@ -377,7 +381,7 @@ private void testHttpMediaTypeNotSupportedException(String url) { .isEqualTo(Collections.singletonList(new MediaType("application", "xml")))); } - private void testHttpOptions(String requestURI, Set allowedMethods, @Nullable MediaType acceptPatch) { + private void testHttpOptions(String requestURI, Set allowedMethods, @Nullable MediaType acceptMediaType) { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI)); HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); @@ -395,8 +399,36 @@ private void testHttpOptions(String requestURI, Set allowedMethods, HttpHeaders headers = (HttpHeaders) value; assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); - if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { - assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); + if (acceptMediaType != null) { + if (headers.getAllow().contains(HttpMethod.PATCH)) { + assertThat(headers.getAcceptPatch()).containsExactly(acceptMediaType); + } + if (headers.getAllow().contains(HttpMethod.QUERY)) { + assertThat(headers.getAcceptQuery()).containsExactly(acceptMediaType); + } + } + } + + private void testHttpHeadQuery(String requestURI, Set allowedMethods, @Nullable MediaType acceptMediaType) { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.head(requestURI)); + HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + + BindingContext bindingContext = new BindingContext(); + InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); + Mono mono = invocable.invoke(exchange, bindingContext); + + HandlerResult result = mono.block(); + assertThat(result).isNotNull(); + + Object value = result.getReturnValue(); + assertThat(value).isNotNull(); + assertThat(value.getClass()).isEqualTo(HttpHeaders.class); + + HttpHeaders headers = (HttpHeaders) value; + assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); + + if (acceptMediaType != null) { + assertThat(headers.getAcceptQuery()).containsExactly(acceptMediaType); } } @@ -492,6 +524,11 @@ public String getBaz() { public void patchBaz(String value) { } + @RequestMapping(value = "/quid", method = RequestMethod.QUERY, consumes = "application/json", produces = "application/json") + public String query(@RequestBody String body) { + return "{}"; + } + 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 0856a0db021d..0b5e366c0cb1 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 @@ -69,9 +69,12 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe private static final Method HTTP_OPTIONS_HANDLE_METHOD; + private static final Method HTTP_HEAD_QUERY_HANDLE_METHOD; + static { try { HTTP_OPTIONS_HANDLE_METHOD = HttpOptionsHandler.class.getMethod("handle"); + HTTP_HEAD_QUERY_HANDLE_METHOD = HttpHeadQueryHandler.class.getMethod("handle"); } catch (NoSuchMethodException ex) { // Should never happen @@ -253,10 +256,16 @@ private Map> extractMatrixVariables( if (helper.hasMethodsMismatch()) { Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.matches(request.getMethod())) { - Set mediaTypes = helper.getConsumablePatchMediaTypes(); - HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); + Set patchMediaTypes = helper.getConsumablePatchMediaTypes(); + Set queryMediaTypes = helper.getConsumableQueryMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, patchMediaTypes, queryMediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } + if (HttpMethod.HEAD.matches(request.getMethod()) && methods.contains(HttpMethod.QUERY.name())) { + Set queryMediaTypes = helper.getConsumableQueryMediaTypes(); + HttpHeadQueryHandler handler = new HttpHeadQueryHandler(methods, queryMediaTypes); + return new HandlerMethod(handler, HTTP_HEAD_QUERY_HANDLE_METHOD); + } throw new HttpRequestMethodNotSupportedException(request.getMethod(), methods); } @@ -437,6 +446,21 @@ public Set getConsumablePatchMediaTypes() { return result; } + /** + * Return declared "consumable" types but only among those that have + * QUERY specified, or that have no methods at all. + */ + public Set getConsumableQueryMediaTypes() { + Set result = new LinkedHashSet<>(); + for (PartialMatch match : this.partialMatches) { + Set methods = match.getInfo().getMethodsCondition().getMethods(); + if (methods.isEmpty() || methods.contains(RequestMethod.QUERY)) { + result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); + } + } + return result; + } + /** * Container for a RequestMappingInfo that matches the URL path at least. @@ -501,9 +525,10 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch, Set acceptQuery) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); + this.headers.setAcceptQuery(new ArrayList<>(acceptQuery)); } private static Set initAllowedHttpMethods(Set declaredMethods) { @@ -519,7 +544,7 @@ private static Set initAllowedHttpMethods(Set declaredMethod for (String method : declaredMethods) { HttpMethod httpMethod = HttpMethod.valueOf(method); result.add(httpMethod); - if (httpMethod == HttpMethod.GET) { + if (httpMethod == HttpMethod.GET || httpMethod == HttpMethod.QUERY) { result.add(HttpMethod.HEAD); } } @@ -534,4 +559,22 @@ public HttpHeaders handle() { } } + /** + * Default handler for HTTP HEAD targeting a QUERY endpoint. + */ + private static class HttpHeadQueryHandler { + + private final HttpHeaders headers = new HttpHeaders(); + + public HttpHeadQueryHandler(Set declaredMethods, Set acceptQuery) { + this.headers.setAllow(HttpOptionsHandler.initAllowedHttpMethods(declaredMethods)); + this.headers.setAcceptQuery(new ArrayList<>(acceptQuery)); + } + + @SuppressWarnings("unused") + public HttpHeaders handle() { + return this.headers; + } + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodArgumentResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodArgumentResolver.java index 68c129a5dbde..f1f1c7e11f0e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodArgumentResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodArgumentResolver.java @@ -74,7 +74,7 @@ public abstract class AbstractMessageConverterMethodArgumentResolver implements protected enum ConverterType { BASE, GENERIC, SMART }; - private static final Set SUPPORTED_METHODS = Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); + private static final Set SUPPORTED_METHODS = Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.QUERY); private static final Object NO_VALUE = new Object(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java index 80d72452312e..b9f4c1adf49d 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java @@ -123,7 +123,7 @@ void excludeMultipleMethods() { testHttpMethods( new HttpMethod[] {}, new HttpMethod[] {HttpMethod.GET, HttpMethod.POST, HttpMethod.OPTIONS}, - "HEAD", "PUT", "DELETE", "TRACE", "PATCH"); + "HEAD", "PUT", "DELETE", "TRACE", "PATCH", "QUERY"); } private void testHttpMethods(HttpMethod[] include, HttpMethod[] exclude, String... expected) { 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 8ca35f451ff2..d2937784e700 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 @@ -193,9 +193,11 @@ void getHandlerMediaTypeNotSupportedWithParseError(TestRequestMappingInfoHandler void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception { 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, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY", null); testHttpOptions(mapping, "/something", "PUT,POST", null); testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar")); + testHttpOptions(mapping, "/quid", "QUERY,HEAD,OPTIONS", null); + testHttpHeadQuery(mapping, "/quid", "QUERY,HEAD,OPTIONS", MediaType.APPLICATION_JSON); } @PathPatternsParameterizedTest @@ -477,6 +479,29 @@ private void testHttpOptions(TestRequestMappingInfoHandlerMapping mapping, Strin } } + private void testHttpHeadQuery(TestRequestMappingInfoHandlerMapping mapping, String requestURI, + String allowHeader, @Nullable MediaType acceptQuery) throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest("HEAD", requestURI); + HandlerMethod handlerMethod = getHandler(mapping, request); + + ServletWebRequest webRequest = new ServletWebRequest(request); + ModelAndViewContainer mavContainer = new ModelAndViewContainer(); + Object result = new InvocableHandlerMethod(handlerMethod).invokeForRequest(webRequest, mavContainer); + + assertThat(result).isNotNull(); + assertThat(result.getClass()).isEqualTo(HttpHeaders.class); + HttpHeaders headers = (HttpHeaders) result; + Set allowedMethods = Arrays.stream(allowHeader.split(",")) + .map(HttpMethod::valueOf) + .collect(Collectors.toSet()); + assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); + + if (acceptQuery != null) { + assertThat(headers.getAcceptQuery()).containsExactly(acceptQuery); + } + } + private void testHttpMediaTypeNotAcceptableException(TestRequestMappingInfoHandlerMapping mapping, String url) { MockHttpServletRequest request = new MockHttpServletRequest("GET", url); request.addHeader("Accept", "application/json"); @@ -572,6 +597,11 @@ public String getBaz() { @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") public void patchBaz(String value) { } + + @RequestMapping(value = "/quid", method = RequestMethod.QUERY, consumes = "application/json", produces = "application/json") + public String query(@RequestBody String body) { + return "{}"; + } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java index c07a3ed8a184..9f303d65522e 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -148,6 +148,22 @@ void patchHttpMediaTypeNotSupported() { assertThat(headers.getFirst(HttpHeaders.ACCEPT_PATCH)).isEqualTo("application/atom+xml, application/xml"); } + @Test + void queryHttpMediaTypeNotSupported() { + this.servletRequest = new MockHttpServletRequest("QUERY", "/"); + this.request = new ServletWebRequest(this.servletRequest, this.servletResponse); + + ResponseEntity entity = testException( + new HttpMediaTypeNotSupportedException( + MediaType.APPLICATION_JSON, + List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML), + HttpMethod.QUERY)); + + HttpHeaders headers = entity.getHeaders(); + assertThat(headers.getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/atom+xml, application/xml"); + assertThat(headers.getFirst(HttpHeaders.ACCEPT_QUERY)).isEqualTo("application/atom+xml, application/xml"); + } + @Test void httpMediaTypeNotAcceptable() { testException(new HttpMediaTypeNotAcceptableException("")); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java index 4970cf998f58..c785f19223d8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java @@ -39,7 +39,7 @@ void getAllowHeaderWithConstructorTrue() { @Test void getAllowHeaderWithConstructorFalse() { WebContentGenerator generator = new TestWebContentGenerator(false); - assertThat(generator.getAllowHeader()).isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); + assertThat(generator.getAllowHeader()).isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY"); } @Test @@ -59,7 +59,7 @@ void getAllowHeaderWithSupportedMethodsSetter() { void getAllowHeaderWithSupportedMethodsSetterEmpty() { WebContentGenerator generator = new TestWebContentGenerator(); generator.setSupportedMethods(); - assertThat(generator.getAllowHeader()).as("Effectively \"no restriction\" on supported methods").isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); + assertThat(generator.getAllowHeader()).as("Effectively \"no restriction\" on supported methods").isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY"); } @Test