From 6f9aa37b79988e68f2508218c1610a977cdba7d2 Mon Sep 17 00:00:00 2001 From: Mario Daniel Ruiz Saavedra Date: Tue, 3 Jun 2025 19:27:40 -0300 Subject: [PATCH] Add QUERY HTTP method Signed-off-by: Mario Daniel Ruiz Saavedra --- .../reactive/MockServerHttpRequest.java | 10 ++ .../reactive/server/DefaultWebTestClient.java | 5 + .../web/reactive/server/WebTestClient.java | 6 + .../web/servlet/assertj/MockMvcTester.java | 14 +++ .../request/MockMvcRequestBuilders.java | 18 +++ .../MockHttpServletRequestBuilderTests.java | 17 ++- .../org/springframework/http/HttpHeaders.java | 27 +++++ .../org/springframework/http/HttpMethod.java | 25 +++-- .../springframework/http/RequestEntity.java | 20 ++++ ...ttpComponentsClientHttpRequestFactory.java | 4 + .../HttpMediaTypeNotSupportedException.java | 3 + .../web/bind/annotation/DeleteMapping.java | 1 + .../web/bind/annotation/GetMapping.java | 1 + .../web/bind/annotation/PatchMapping.java | 1 + .../web/bind/annotation/PostMapping.java | 1 + .../web/bind/annotation/PutMapping.java | 1 + .../web/bind/annotation/QueryMapping.java | 104 ++++++++++++++++++ .../web/bind/annotation/RequestMapping.java | 7 +- .../web/bind/annotation/RequestMethod.java | 6 +- .../web/client/DefaultRestClient.java | 5 + .../web/client/RestClient.java | 6 + .../web/cors/CorsConfiguration.java | 4 +- .../web/filter/ShallowEtagHeaderFilter.java | 2 +- .../UnsupportedMediaTypeStatusException.java | 3 + .../adapter/DefaultServerWebExchange.java | 2 +- .../web/service/annotation/HttpExchange.java | 1 + .../web/service/annotation/QueryExchange.java | 53 +++++++++ .../web/client/RestOperationsExtensions.kt | 44 ++++++++ .../springframework/http/HttpMethodTests.java | 4 +- ...rollerMappingReflectiveProcessorTests.java | 16 +++ .../client/AbstractMockWebServerTests.java | 27 ++++- .../client/RestTemplateIntegrationTests.java | 2 +- .../web/client/RestTemplateTests.java | 42 +++++++ .../web/cors/CorsConfigurationTests.java | 10 +- .../web/cors/DefaultCorsProcessorTests.java | 2 +- .../reactive/DefaultCorsProcessorTests.java | 2 +- .../method/MvcAnnotationPredicates.java | 4 + .../function/client/DefaultWebClient.java | 5 + .../reactive/function/client/WebClient.java | 6 + .../server/DefaultServerResponseBuilder.java | 2 +- .../function/server/RequestPredicates.java | 12 ++ .../server/ResourceHandlerFunction.java | 8 +- .../server/RouterFunctionBuilder.java | 24 ++++ .../function/server/RouterFunctions.java | 52 +++++++++ .../reactive/resource/ResourceWebHandler.java | 2 +- .../RequestMethodsRequestCondition.java | 6 + .../RequestMappingInfoHandlerMapping.java | 35 ++++-- .../ResponseEntityResultHandler.java | 2 +- .../server/ResourceHandlerFunctionTests.java | 4 +- ...RequestMappingInfoHandlerMappingTests.java | 12 +- .../GlobalCorsConfigIntegrationTests.java | 2 +- .../function/AbstractServerResponse.java | 2 +- .../servlet/function/RequestPredicates.java | 12 ++ .../function/ResourceHandlerFunction.java | 7 +- .../function/RouterFunctionBuilder.java | 24 ++++ .../web/servlet/function/RouterFunctions.java | 51 +++++++++ .../RequestMethodsRequestCondition.java | 6 + .../RequestMappingInfoHandlerMapping.java | 2 +- .../annotation/HttpEntityMethodProcessor.java | 4 +- .../resource/ResourceHttpRequestHandler.java | 2 +- .../web/servlet/config/MvcNamespaceTests.java | 4 +- .../ResourceHandlerFunctionTests.java | 4 +- .../handler/HandlerMethodMappingTests.java | 2 +- ...RequestMappingInfoHandlerMappingTests.java | 8 +- .../ResponseEntityExceptionHandlerTests.java | 17 +++ .../ResourceHttpRequestHandlerTests.java | 2 +- .../support/WebContentGeneratorTests.java | 4 +- 67 files changed, 752 insertions(+), 71 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/bind/annotation/QueryMapping.java create mode 100644 spring-web/src/main/java/org/springframework/web/service/annotation/QueryExchange.java diff --git a/spring-test/src/main/java/org/springframework/mock/http/server/reactive/MockServerHttpRequest.java b/spring-test/src/main/java/org/springframework/mock/http/server/reactive/MockServerHttpRequest.java index d28816d52b..06c28a0b5a 100644 --- a/spring-test/src/main/java/org/springframework/mock/http/server/reactive/MockServerHttpRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/http/server/reactive/MockServerHttpRequest.java @@ -184,6 +184,16 @@ public final class MockServerHttpRequest extends AbstractServerHttpRequest { return method(HttpMethod.OPTIONS, urlTemplate, uriVars); } + /** + * HTTP POST variant. See {@link #get(String, Object...)} for general info. + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriVars zero or more URI variables + * @return the created builder + */ + public static BodyBuilder query(String urlTemplate, @Nullable Object... uriVars) { + return method(HttpMethod.QUERY, urlTemplate, uriVars); + } + /** * Create a builder with the given HTTP method and a {@link URI}. * @param method the HTTP method (GET, POST, etc) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 60b39d57e7..84d05bd4b7 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -165,6 +165,11 @@ class DefaultWebTestClient implements WebTestClient { return methodInternal(HttpMethod.OPTIONS); } + @Override + public RequestBodyUriSpec query() { + return methodInternal(HttpMethod.QUERY); + } + @Override public RequestBodyUriSpec method(HttpMethod httpMethod) { return methodInternal(httpMethod); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index b7018bcdda..c47a046fa1 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -146,6 +146,12 @@ public interface WebTestClient { */ RequestHeadersUriSpec options(); + /** + * Prepare an HTTP QUERY request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec query(); + /** * Prepare a request for the specified {@code HttpMethod}. * @return a spec for specifying the target URL diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java index 9c8cfcc50d..af3651fee6 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java @@ -333,6 +333,20 @@ public final class MockMvcTester { return method(HttpMethod.OPTIONS); } + /** + * Prepare an HTTP QUERY request. + *

The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder query() { + return method(HttpMethod.QUERY); + } + /** * Prepare a request for the specified {@code HttpMethod}. *

The returned builder can be wrapped in {@code assertThat} to enable diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java index abf4793910..f7560f302e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java @@ -175,6 +175,24 @@ public abstract class MockMvcRequestBuilders { return new MockHttpServletRequestBuilder(HttpMethod.HEAD).uri(uri); } + /** + * Create a {@link MockHttpServletRequestBuilder} for a QUERY request. + * @param uriTemplate a URI template; the resulting URI will be encoded + * @param uriVariables zero or more URI variables + */ + public static MockHttpServletRequestBuilder query(String uriTemplate, @Nullable Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.QUERY).uri(uriTemplate, uriVariables); + } + + /** + * Create a {@link MockHttpServletRequestBuilder} for a QUERY request. + * @param uri the URI + * @since x.x.x + */ + public static MockHttpServletRequestBuilder query(URI uri) { + return new MockHttpServletRequestBuilder(HttpMethod.QUERY).uri(uri); + } + /** * Create a {@link MockHttpServletRequestBuilder} for a request with the given HTTP method. * @param method the HTTP method (GET, POST, etc.) diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java index 5c09d17fa0..4fc0696ad6 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java @@ -32,6 +32,7 @@ import jakarta.servlet.http.Cookie; import org.assertj.core.api.ThrowingConsumer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -53,6 +54,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.entry; import static org.springframework.http.HttpMethod.GET; import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.HttpMethod.QUERY; /** * Tests for building a {@link MockHttpServletRequest} with @@ -391,17 +393,20 @@ class MockHttpServletRequestBuilderTests { } @Test + @ParameterizedTest() void requestParameterFromRequestBodyFormData() { String contentType = "application/x-www-form-urlencoded;charset=UTF-8"; String body = "name+1=value+1&name+2=value+A&name+2=value+B&name+3"; - MockHttpServletRequest request = new MockHttpServletRequestBuilder(POST).uri("/foo") - .contentType(contentType).content(body.getBytes(UTF_8)) - .buildRequest(this.servletContext); + for (HttpMethod method : List.of(POST, QUERY)) { + MockHttpServletRequest request = new MockHttpServletRequestBuilder(method).uri("/foo") + .contentType(contentType).content(body.getBytes(UTF_8)) + .buildRequest(this.servletContext); - assertThat(request.getParameterMap().get("name 1")).containsExactly("value 1"); - assertThat(request.getParameterMap().get("name 2")).containsExactly("value A", "value B"); - assertThat(request.getParameterMap().get("name 3")).containsExactly((String) null); + assertThat(request.getParameterMap().get("name 1")).containsExactly("value 1"); + assertThat(request.getParameterMap().get("name 2")).containsExactly("value A", "value B"); + assertThat(request.getParameterMap().get("name 3")).containsExactly((String) null); + } } @Test 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 ee4d794e0e..499d757093 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -130,6 +130,12 @@ public class HttpHeaders implements Serializable { * @see Section 5.3.5 of RFC 7233 */ public static final String ACCEPT_RANGES = "Accept-Ranges"; + + /** + * The HTTP {@code Accept-Query} header field name. + * @see IETF Draft + */ + public static final String ACCEPT_QUERY = "Accept-Query"; /** * The CORS {@code Access-Control-Allow-Credentials} response header field name. * @see CORS W3C recommendation @@ -635,6 +641,27 @@ public class HttpHeaders implements Serializable { 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 x.x.x + */ + 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 x.x.x + */ + 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 95debcfbca..390188e0b4 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpMethod.java +++ b/spring-web/src/main/java/org/springframework/http/HttpMethod.java @@ -37,25 +37,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"); @@ -67,23 +67,29 @@ 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}. + * @see IETF Draft + */ + 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; @@ -97,7 +103,7 @@ public final class HttpMethod implements Comparable, Serializable { * 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. @@ -124,6 +130,7 @@ public final class HttpMethod implements Comparable, Serializable { 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/RequestEntity.java b/spring-web/src/main/java/org/springframework/http/RequestEntity.java index 64fc4316f0..f8847fe814 100644 --- a/spring-web/src/main/java/org/springframework/http/RequestEntity.java +++ b/spring-web/src/main/java/org/springframework/http/RequestEntity.java @@ -382,6 +382,26 @@ public class RequestEntity extends HttpEntity { return method(HttpMethod.POST, uriTemplate, uriVariables); } + /** + * Create an HTTP QUERY builder with the given url. + * @param url the URL + * @return the created builder + */ + public static BodyBuilder query(URI url) { + return method(HttpMethod.QUERY, url); + } + + /** + * Create an HTTP QUERY builder with the given string base uri template. + * @param uriTemplate the uri template to use + * @param uriVariables variables to expand the URI template with + * @return the created builder + * @since x.x.x + */ + public static BodyBuilder query(String uriTemplate, Object... uriVariables) { + return method(HttpMethod.QUERY, uriTemplate, uriVariables); + } + /** * Create an HTTP PUT builder with the given url. * @param url the URL 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 d366eded45..c82d608349 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.HttpPatch; 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; @@ -345,6 +346,9 @@ public class HttpComponentsClientHttpRequestFactory implements ClientHttpRequest 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 bcba1c9953..3441e371e7 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 class HttpMediaTypeNotSupportedException extends HttpMediaTypeException { if (HttpMethod.PATCH.equals(this.httpMethod)) { headers.setAcceptPatch(getSupportedMediaTypes()); } + if (HttpMethod.QUERY.equals(this.httpMethod)) { + headers.setAcceptQuery(getSupportedMediaTypes()); + } return headers; } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java index ef09e51240..4a71e0f8ae 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java @@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor; * @see PostMapping * @see PutMapping * @see PatchMapping + * @see QueryMapping * @see RequestMapping */ @Target(ElementType.METHOD) diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java index 5c1e962ac4..4135f865c8 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java @@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor; * @see PutMapping * @see DeleteMapping * @see PatchMapping + * @see QueryMapping * @see RequestMapping */ @Target(ElementType.METHOD) diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java index 1cce89e254..60902058c6 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java @@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor; * @see PostMapping * @see PutMapping * @see DeleteMapping + * @see QueryMapping * @see RequestMapping */ @Target(ElementType.METHOD) diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java index 8cfb36a6e3..943bac1750 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java @@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor; * @see PutMapping * @see DeleteMapping * @see PatchMapping + * @see QueryMapping * @see RequestMapping */ @Target(ElementType.METHOD) diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java index 8cfc46101a..86f551eb78 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java @@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor; * @see PostMapping * @see DeleteMapping * @see PatchMapping + * @see QueryMapping * @see RequestMapping */ @Target(ElementType.METHOD) diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/QueryMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/QueryMapping.java new file mode 100644 index 0000000000..42af68d8a0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/QueryMapping.java @@ -0,0 +1,104 @@ +/* + * Copyright 2002-2024 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.bind.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation for mapping HTTP {@code QUERY} requests onto specific handler + * methods. + * + *

Specifically, {@code @QueryMapping} is a composed annotation that + * acts as a shortcut for {@code @RequestMapping(method = RequestMethod.QUERY)}. + * + *

NOTE: This annotation cannot be used in conjunction with + * other {@code @RequestMapping} annotations that are declared on the same method. + * If multiple {@code @RequestMapping} annotations are detected on the same method, + * a warning will be logged, and only the first mapping will be used. This applies + * to {@code @RequestMapping} as well as composed {@code @RequestMapping} annotations + * such as {@code @GetMapping}, {@code @PutMapping}, etc. + * + * @author Mario Ruiz + * @since x.x.x + * @see GetMapping + * @see PutMapping + * @see PostMapping + * @see DeleteMapping + * @see PatchMapping + * @see RequestMapping + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@RequestMapping(method = RequestMethod.QUERY) +public @interface QueryMapping { + + /** + * Alias for {@link RequestMapping#name}. + */ + @AliasFor(annotation = RequestMapping.class) + String name() default ""; + + /** + * Alias for {@link RequestMapping#value}. + */ + @AliasFor(annotation = RequestMapping.class) + String[] value() default {}; + + /** + * Alias for {@link RequestMapping#path}. + */ + @AliasFor(annotation = RequestMapping.class) + String[] path() default {}; + + /** + * Alias for {@link RequestMapping#params}. + */ + @AliasFor(annotation = RequestMapping.class) + String[] params() default {}; + + /** + * Alias for {@link RequestMapping#headers}. + */ + @AliasFor(annotation = RequestMapping.class) + String[] headers() default {}; + + /** + * Alias for {@link RequestMapping#consumes}. + */ + @AliasFor(annotation = RequestMapping.class) + String[] consumes() default {}; + + /** + * Alias for {@link RequestMapping#produces}. + */ + @AliasFor(annotation = RequestMapping.class) + String[] produces() default {}; + + /** + * Alias for {@link RequestMapping#version()}. + */ + @AliasFor(annotation = RequestMapping.class) + String version() default ""; + +} diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index 6cb0386e84..663f51f553 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -51,8 +51,8 @@ import org.springframework.core.annotation.AliasFor; * at the method level. In most cases, at the method level applications will * prefer to use one of the HTTP method specific variants * {@link GetMapping @GetMapping}, {@link PostMapping @PostMapping}, - * {@link PutMapping @PutMapping}, {@link DeleteMapping @DeleteMapping}, or - * {@link PatchMapping @PatchMapping}. + * {@link PutMapping @PutMapping}, {@link DeleteMapping @DeleteMapping}, + * {@link PatchMapping @PatchMapping}, or {@link QueryMapping}. * *

NOTE: This annotation cannot be used in conjunction with * other {@code @RequestMapping} annotations that are declared on the same element @@ -75,6 +75,7 @@ import org.springframework.core.annotation.AliasFor; * @see PutMapping * @see DeleteMapping * @see PatchMapping + * @see QueryMapping */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -121,7 +122,7 @@ public @interface RequestMapping { /** * The HTTP request methods to map to, narrowing the primary mapping: - * GET, POST, HEAD, OPTIONS, PUT, PATCH, DELETE, TRACE. + * GET, POST, HEAD, OPTIONS, PUT, PATCH, DELETE, TRACE, QUERY. *

Supported at the type level as well as at the method level! * When used at the type level, all method-level mappings inherit this * HTTP method restriction. 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 b1f5438b90..e1ca546ead 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 @@ import org.springframework.util.Assert; * {@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 @@ import org.springframework.util.Assert; */ 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 enum RequestMethod { 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/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index d39e43bd5f..4db69a3f9b 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -193,6 +193,11 @@ final class DefaultRestClient implements RestClient { return methodInternal(HttpMethod.OPTIONS); } + @Override + public RequestHeadersUriSpec query() { + return methodInternal(HttpMethod.QUERY); + } + @Override public RequestBodyUriSpec method(HttpMethod method) { Assert.notNull(method, "HttpMethod must not be null"); diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClient.java b/spring-web/src/main/java/org/springframework/web/client/RestClient.java index 9804bf55ba..4b8b5ce2d9 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClient.java @@ -123,6 +123,12 @@ public interface RestClient { */ RequestHeadersUriSpec options(); + /** + * Start building an HTTP QUERY request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec query(); + /** * Start building a request for the given {@code HttpMethod}. * @return a spec for specifying the target URL diff --git a/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java b/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java index 6873edf5d6..c8e6049f9d 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java +++ b/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java @@ -63,9 +63,9 @@ public class CorsConfiguration { private static final List DEFAULT_PERMIT_ALL = Collections.singletonList(ALL); - private static final List DEFAULT_METHODS = List.of(HttpMethod.GET, HttpMethod.HEAD); + private static final List DEFAULT_METHODS = List.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); - private static final List DEFAULT_PERMIT_METHODS = List.of(HttpMethod.GET.name(), + private static final List DEFAULT_PERMIT_METHODS = List.of(HttpMethod.GET.name(), HttpMethod.QUERY.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name()); diff --git a/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java index 3564cc8629..0b826637e7 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java @@ -151,7 +151,7 @@ public class ShallowEtagHeaderFilter extends OncePerRequestFilter { if (!response.isCommitted() && responseStatusCode >= 200 && responseStatusCode < 300 && - HttpMethod.GET.matches(request.getMethod())) { + (HttpMethod.GET.matches(request.getMethod()) || HttpMethod.QUERY.matches(request.getMethod()))) { String cacheControl = response.getHeader(HttpHeaders.CACHE_CONTROL); return (cacheControl == null || !cacheControl.contains(DIRECTIVE_NO_STORE)); 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 f38dd40b95..14ca50d4b3 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 class UnsupportedMediaTypeStatusException extends ResponseStatusException if (this.method == HttpMethod.PATCH) { headers.setAcceptPatch(this.supportedMediaTypes); } + if (this.method == HttpMethod.QUERY) { + headers.setAcceptQuery(this.supportedMediaTypes); + } return headers; } diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java index a3ec76997b..6a61fcfd45 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java @@ -66,7 +66,7 @@ import org.springframework.web.server.session.WebSessionManager; */ public class DefaultServerWebExchange implements ServerWebExchange { - private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); private static final ResolvableType FORM_DATA_TYPE = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java index 745e59533d..2b12bed9ea 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java @@ -51,6 +51,7 @@ import org.springframework.web.util.UriBuilderFactory; *

  • {@link PutExchange} *
  • {@link PatchExchange} *
  • {@link DeleteExchange} + *
  • {@link QueryExchange} * * *

    Supported method arguments: diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/QueryExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/QueryExchange.java new file mode 100644 index 0000000000..21e04047c4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/QueryExchange.java @@ -0,0 +1,53 @@ +package org.springframework.web.service.annotation; + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +/** + * Shortcut for {@link HttpExchange @HttpExchange} for HTTP QUERY requests. + * + * @author Mario Ruiz + * @since x.x.x + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@HttpExchange(method = "QUERY") +public @interface QueryExchange { + /** + * Alias for {@link HttpExchange#value}. + */ + @AliasFor(annotation = HttpExchange.class) + String value() default ""; + + /** + * Alias for {@link HttpExchange#url()}. + */ + @AliasFor(annotation = HttpExchange.class) + String url() default ""; + + /** + * Alias for {@link HttpExchange#contentType()}. + */ + @AliasFor(annotation = HttpExchange.class) + String contentType() default ""; + + /** + * Alias for {@link HttpExchange#accept()}. + */ + @AliasFor(annotation = HttpExchange.class) + String[] accept() default {}; + + /** + * Alias for {@link HttpExchange#headers()}. + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + + /** + * Alias for {@link HttpExchange#version()}. + */ + @AliasFor(annotation = HttpExchange.class) + String version() default ""; +} diff --git a/spring-web/src/main/kotlin/org/springframework/web/client/RestOperationsExtensions.kt b/spring-web/src/main/kotlin/org/springframework/web/client/RestOperationsExtensions.kt index 61ebf19faa..6b291f1606 100644 --- a/spring-web/src/main/kotlin/org/springframework/web/client/RestOperationsExtensions.kt +++ b/spring-web/src/main/kotlin/org/springframework/web/client/RestOperationsExtensions.kt @@ -18,9 +18,11 @@ package org.springframework.web.client import org.springframework.core.ParameterizedTypeReference import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.RequestEntity import org.springframework.http.ResponseEntity +import org.springframework.web.client.queryForEntity import java.lang.Class import java.net.URI import kotlin.reflect.KClass @@ -291,3 +293,45 @@ inline fun RestOperations.exchange(url: URI, method: HttpMetho @Throws(RestClientException::class) inline fun RestOperations.exchange(requestEntity: RequestEntity<*>): ResponseEntity = exchange(requestEntity, object : ParameterizedTypeReference() {}) + +/** + * Extension for [RestOperations.postForEntity] providing a `postForEntity(...)` + * variant leveraging Kotlin reified type parameters. Like the original Java method, this + * extension is subject to type erasure. Use [exchange] if you need to retain actual + * generic type arguments. + * + * @author Mario Ruiz + * @since x.x.x + */ +@Throws(RestClientException::class) +inline fun RestOperations.queryForEntity(url: String, request: Any? = null, + vararg uriVariables: Any?): ResponseEntity = + exchange(url = url, method = HttpMethod.QUERY, requestEntity = HttpEntity(request, null as (HttpHeaders?) ), uriVariables= uriVariables) + +/** + * Extension for [RestOperations.postForEntity] providing a `postForEntity(...)` + * variant leveraging Kotlin reified type parameters. Like the original Java method, this + * extension is subject to type erasure. Use [exchange] if you need to retain actual + * generic type arguments. + * + * @author Mario Ruiz + * @since x.x.x + */ +@Throws(RestClientException::class) +inline fun RestOperations.queryForEntity(url: String, request: Any? = null, + uriVariables: Map): ResponseEntity = + exchange(url = url, method = HttpMethod.QUERY, requestEntity = HttpEntity(request, null as (HttpHeaders?) ), uriVariables= uriVariables) + +/** + * Extension for [RestOperations.postForEntity] providing a `postForEntity(...)` + * variant leveraging Kotlin reified type parameters. Like the original Java method, this + * extension is subject to type erasure. Use [exchange] if you need to retain actual + * generic type arguments. + * + * @author Mario Ruiz + * @since x.x.x + */ +@Throws(RestClientException::class) +inline fun RestOperations.queryForEntity(url: URI, request: Any? = null): ResponseEntity = + exchange(url = url, method = HttpMethod.QUERY, requestEntity = HttpEntity(request, null as (HttpHeaders?) )) + 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 055149ea53..eb5aff2f69 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 @@ class HttpMethodTests { 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/ControllerMappingReflectiveProcessorTests.java b/spring-web/src/test/java/org/springframework/web/bind/annotation/ControllerMappingReflectiveProcessorTests.java index b46afe89e9..f8969a3979 100644 --- a/spring-web/src/test/java/org/springframework/web/bind/annotation/ControllerMappingReflectiveProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/bind/annotation/ControllerMappingReflectiveProcessorTests.java @@ -213,6 +213,11 @@ class ControllerMappingReflectiveProcessorTests { void post(@RequestBody Request request) { } + @QueryMapping + Response query(@RequestBody Request request) { + return new Response("response"); + } + @PostMapping void postForm(@ModelAttribute Request request) { } @@ -247,6 +252,17 @@ class ControllerMappingReflectiveProcessorTests { void postPartToConvert(@RequestPart Request request) { } + @QueryMapping + HttpEntity querytHttpEntity(HttpEntity entity) { + return new HttpEntity<>(new Response("response")); + } + + @QueryMapping + @SuppressWarnings("rawtypes") + HttpEntity queryRawHttpEntity(HttpEntity entity) { + return new HttpEntity(new Response("response")); + } + } @RestController 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 18b8815e95..05384e7324 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 @@ -74,7 +74,7 @@ abstract class AbstractMockWebServerTests { private MockResponse getRequest(RecordedRequest request, byte[] body, String contentType) { if (request.getMethod().equals("OPTIONS")) { - return new MockResponse().setResponseCode(200).setHeader("Allow", "GET, OPTIONS, HEAD, TRACE"); + return new MockResponse().setResponseCode(200).setHeader("Allow", "GET, QUERY, OPTIONS, HEAD, TRACE"); } Buffer buf = new Buffer(); buf.write(body); @@ -231,6 +231,28 @@ abstract class AbstractMockWebServerTests { return new MockResponse().setResponseCode(202); } + private MockResponse queryRequest(RecordedRequest request, String expectedRequestContent, + String contentType, byte[] responseBody) { + + assertThat(request.getHeaders().values(CONTENT_LENGTH)).hasSize(1); + assertThat(Integer.parseInt(request.getHeader(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); + String requestContentType = request.getHeader(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().readString(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); + Buffer buf = new Buffer(); + buf.write(responseBody); + return new MockResponse() + .setHeader(CONTENT_TYPE, contentType) + .setHeader(CONTENT_LENGTH, responseBody.length) + .setBody(buf) + .setResponseCode(200); + } + protected class TestDispatcher extends Dispatcher { @@ -293,6 +315,9 @@ abstract class AbstractMockWebServerTests { else if (request.getPath().equals("/put")) { return putRequest(request, helloWorld); } + else if (request.getPath().equals("/query")) { + return queryRequest(request, helloWorld, textContentType.toString(), helloWorldBytes); + } return new MockResponse().setResponseCode(404); } 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 022309b308..b8910210dc 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 @@ -290,7 +290,7 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests { 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/client/RestTemplateTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java index 4c421a56bd..13b047918e 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java @@ -43,6 +43,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.RequestEntity; import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestInitializer; @@ -75,6 +76,7 @@ import static org.springframework.http.HttpMethod.OPTIONS; import static org.springframework.http.HttpMethod.PATCH; import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.HttpMethod.PUT; +import static org.springframework.http.HttpMethod.QUERY; import static org.springframework.http.MediaType.parseMediaType; /** @@ -478,6 +480,46 @@ class RestTemplateTests { verify(response).close(); } + @Test + void queryForEntity() throws Exception { + mockTextPlainHttpMessageConverter(); + HttpHeaders requestHeaders = new HttpHeaders(); + mockSentRequest(QUERY, "https://example.com", requestHeaders); + mockResponseStatus(HttpStatus.OK); + String expected = "42"; + mockResponseBody(expected, MediaType.TEXT_PLAIN); + + ResponseEntity result = template.exchange(RequestEntity.query("https://example.com").body("Hello World"), String.class); + assertThat(result.getBody()).as("Invalid QUERY result").isEqualTo(expected); + assertThat(result.getHeaders().getContentType()).as("Invalid Content-Type").isEqualTo(MediaType.TEXT_PLAIN); + assertThat(requestHeaders.getFirst("Accept")).as("Invalid Accept header").isEqualTo(MediaType.TEXT_PLAIN_VALUE); + assertThat(result.getStatusCode()).as("Invalid status code").isEqualTo(HttpStatus.OK); + + verify(response).close(); + } + + @Test + void queryForEntityNull() throws Exception { + mockTextPlainHttpMessageConverter(); + HttpHeaders requestHeaders = new HttpHeaders(); + mockSentRequest(QUERY, "https://example.com", requestHeaders); + mockResponseStatus(HttpStatus.OK); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.TEXT_PLAIN); + responseHeaders.setContentLength(10); + given(response.getHeaders()).willReturn(responseHeaders); + given(response.getBody()).willReturn(InputStream.nullInputStream()); + given(converter.read(String.class, response)).willReturn(null); + + ResponseEntity result = template.exchange("https://example.com",QUERY, null, String.class); + assertThat(result.hasBody()).as("Invalid QUERY result").isFalse(); + assertThat(result.getHeaders().getContentType()).as("Invalid Content-Type").isEqualTo(MediaType.TEXT_PLAIN); + assertThat(requestHeaders.getContentLength()).as("Invalid content length").isEqualTo(0); + assertThat(result.getStatusCode()).as("Invalid status code").isEqualTo(HttpStatus.OK); + + verify(response).close(); + } + @Test void put() throws Exception { mockTextPlainHttpMessageConverter(); diff --git a/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java b/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java index 092781475f..1f8fb86ea4 100644 --- a/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java +++ b/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java @@ -140,7 +140,7 @@ class CorsConfigurationTests { assertThat(config.getAllowedHeaders()).containsExactly("*"); assertThat(combinedConfig).isNotNull(); assertThat(combinedConfig.getAllowedMethods()) - .containsExactly(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name()); + .containsExactly(HttpMethod.GET.name(), HttpMethod.QUERY.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name()); assertThat(combinedConfig.getExposedHeaders()).isEmpty(); combinedConfig = new CorsConfiguration().combine(config); @@ -148,7 +148,7 @@ class CorsConfigurationTests { assertThat(config.getAllowedHeaders()).containsExactly("*"); assertThat(combinedConfig).isNotNull(); assertThat(combinedConfig.getAllowedMethods()) - .containsExactly(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name()); + .containsExactly(HttpMethod.GET.name(), HttpMethod.QUERY.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name()); assertThat(combinedConfig.getExposedHeaders()).isEmpty(); } @@ -394,7 +394,9 @@ class CorsConfigurationTests { @Test void checkMethodAllowed() { CorsConfiguration config = new CorsConfiguration(); - assertThat(config.checkHttpMethod(HttpMethod.GET)).containsExactly(HttpMethod.GET, HttpMethod.HEAD); + assertThat(config.checkHttpMethod(HttpMethod.GET)).containsExactly(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); + assertThat(config.checkHttpMethod(HttpMethod.QUERY)).containsExactly(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); + config.addAllowedMethod("GET"); assertThat(config.checkHttpMethod(HttpMethod.GET)).containsExactly(HttpMethod.GET); @@ -450,7 +452,7 @@ class CorsConfigurationTests { assertThat(config.getAllowedOrigins()).containsExactly("*", "https://domain.com"); assertThat(config.getAllowedHeaders()).containsExactly("*", "header1"); - assertThat(config.getAllowedMethods()).containsExactly("GET", "HEAD", "POST", "PATCH"); + assertThat(config.getAllowedMethods()).containsExactly("GET", "QUERY", "HEAD", "POST", "PATCH"); } @Test diff --git a/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java b/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java index bcb4ae8523..b5add60571 100644 --- a/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java @@ -252,7 +252,7 @@ class DefaultCorsProcessorTests { this.processor.processRequest(this.conf, this.request, this.response); assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); - assertThat(this.response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isEqualTo("GET,HEAD"); + assertThat(this.response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isEqualTo("GET,QUERY,HEAD"); assertThat(this.response.getHeaders(HttpHeaders.VARY)).contains(HttpHeaders.ORIGIN, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS); } diff --git a/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java b/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java index 9881759905..38253acdd0 100644 --- a/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java @@ -263,7 +263,7 @@ class DefaultCorsProcessorTests { assertThat(response.getStatusCode()).isNull(); assertThat(response.getHeaders().get(VARY)).contains(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS); - assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isEqualTo("GET,HEAD"); + assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isEqualTo("GET,QUERY,HEAD"); } @Test 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 9bde5d3eab..81bc715fcb 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 @@ -113,6 +113,10 @@ public class MvcAnnotationPredicates { return new RequestMappingPredicate(path).method(RequestMethod.HEAD); } + public static RequestMappingPredicate queryMapping(String... path) { + return new RequestMappingPredicate(path).method(RequestMethod.QUERY); + } + public static class ModelAttributePredicate implements Predicate { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java index f16d0c1a46..49f0252509 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java @@ -177,6 +177,11 @@ final class DefaultWebClient implements WebClient { return methodInternal(HttpMethod.OPTIONS); } + @Override + public RequestHeadersUriSpec query() { + return methodInternal(HttpMethod.QUERY); + } + @Override public RequestBodyUriSpec method(HttpMethod httpMethod) { return methodInternal(httpMethod); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java index 1b07d10165..f254a1864e 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java @@ -123,6 +123,12 @@ public interface WebClient { */ RequestHeadersUriSpec options(); + /** + * Start building an HTTP QUERY request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec query(); + /** * Start building a request for the given {@code HttpMethod}. * @return a spec for specifying the target URL diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java index 090f0450b0..67151f2c00 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java @@ -288,7 +288,7 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { */ abstract static class AbstractServerResponse implements ServerResponse { - private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); private final HttpStatusCode statusCode; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java index 6ee7be2c2c..d3697d9979 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java @@ -266,6 +266,18 @@ public abstract class RequestPredicates { return method(HttpMethod.OPTIONS).and(path(pattern)); } + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code QUERY} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is QUERY and if the given pattern + * matches against the request path + * @see org.springframework.web.util.pattern.PathPattern + */ + public static RequestPredicate QUERY(String pattern) { + return method(HttpMethod.QUERY).and(path(pattern)); + } + /** * Return a {@code RequestPredicate} that matches if the request's path has the given extension. * @param extension the path extension to match against, ignoring case diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ResourceHandlerFunction.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ResourceHandlerFunction.java index 56ea5b1fb7..c82c9c1347 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ResourceHandlerFunction.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ResourceHandlerFunction.java @@ -43,7 +43,7 @@ import org.springframework.web.reactive.function.BodyInserters; class ResourceHandlerFunction implements HandlerFunction { private static final Set SUPPORTED_METHODS = - Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS); private final Resource resource; @@ -66,6 +66,12 @@ class ResourceHandlerFunction implements HandlerFunction { .build() .map(response -> response); } + else if (HttpMethod.QUERY.equals(method)) { + return EntityResponse.fromObject(this.resource) + .headers(headers -> this.headersConsumer.accept(this.resource, headers)) + .build() + .map(response -> response); + } else if (HttpMethod.HEAD.equals(method)) { Resource headResource = new HeadMethodResource(this.resource); return EntityResponse.fromObject(headResource) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctionBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctionBuilder.java index e8133b2f5c..76bd64dcb1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctionBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctionBuilder.java @@ -231,6 +231,30 @@ class RouterFunctionBuilder implements RouterFunctions.Builder { return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction); } + // QUERY + + @Override + public RouterFunctions.Builder QUERY(HandlerFunction handlerFunction) { + return add(RequestPredicates.method(HttpMethod.QUERY), handlerFunction); + } + + @Override + public RouterFunctions.Builder QUERY(RequestPredicate predicate, HandlerFunction handlerFunction) { + return add(RequestPredicates.method(HttpMethod.QUERY).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder QUERY(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.QUERY(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder QUERY(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.QUERY(pattern).and(predicate), handlerFunction); + } + // other @Override diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java index 8331ceb0f5..ad1397f01c 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java @@ -696,6 +696,58 @@ public abstract class RouterFunctions { */ Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + /** + * Adds a route to the given handler function that handles HTTP {@code QUERY} requests. + * @param handlerFunction the handler function to handle all {@code QUERY} requests + * @return this builder + * @since x.x.x + */ + Builder QUERY(HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code QUERY} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code QUERY} requests that + * match {@code pattern} + * @return this builder + * @see org.springframework.web.util.pattern.PathPattern + */ + Builder QUERY(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code QUERY} requests + * that match the given predicate. + * @param predicate predicate to match + * @param handlerFunction the handler function to handle all {@code QUERY} requests that + * match {@code predicate} + * @return this builder + * @since x.x.x + * @see RequestPredicates + */ + Builder QUERY(RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code QUERY} requests + * that match the given pattern and predicate. + *

    For instance, the following example routes QUERY requests for "/user" that contain JSON + * to the {@code addUser} method in {@code userController}: + *

    +		 * RouterFunction<ServerResponse> route =
    +		 *   RouterFunctions.route()
    +		 *     .QUERY("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::addUser)
    +		 *     .build();
    +		 * 
    + * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code QUERY} requests that + * match {@code pattern} + * @return this builder + * @see org.springframework.web.util.pattern.PathPattern + */ + Builder QUERY(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** * Adds a route to the given handler function that handles all requests that match the * given predicate. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java index d108080a10..4879981463 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java @@ -85,7 +85,7 @@ import org.springframework.web.util.pattern.PathPattern; */ public class ResourceWebHandler implements WebHandler, InitializingBean { - private static final Set SUPPORTED_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set SUPPORTED_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); private static final Log logger = LogFactory.getLog(ResourceWebHandler.class); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java index 9289aee8fe..4257c366f2 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java @@ -157,6 +157,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.GET)) { return requestMethodConditionCache.get(HttpMethod.GET); } + if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.QUERY)) { + return requestMethodConditionCache.get(HttpMethod.QUERY); + } } return null; } @@ -184,6 +187,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) { return 1; } + else if (this.methods.contains(RequestMethod.QUERY) && other.methods.contains(RequestMethod.HEAD)) { + return 1; + } } return 0; } 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 73e8146714..d1b345fa0a 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 @@ -189,8 +189,9 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe 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); } throw new MethodNotAllowedException(httpMethod, methods); @@ -323,14 +324,23 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe * 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 +410,10 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe 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 +424,7 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe } 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); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java index 1961429a1d..077db094b4 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java @@ -56,7 +56,7 @@ import org.springframework.web.server.ServerWebExchange; */ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHandler implements HandlerResultHandler { - private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); /** diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/ResourceHandlerFunctionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/ResourceHandlerFunctionTests.java index 8d270c98a3..141f60d0a9 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/ResourceHandlerFunctionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/ResourceHandlerFunctionTests.java @@ -141,7 +141,7 @@ class ResourceHandlerFunctionTests { Mono responseMono = this.handlerFunction.handle(request); Mono result = responseMono.flatMap(response -> { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.headers().getAllow()).isEqualTo(Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS)); + assertThat(response.headers().getAllow()).isEqualTo(Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS, HttpMethod.QUERY)); return response.writeTo(exchange, context); }); @@ -150,7 +150,7 @@ class ResourceHandlerFunctionTests { .expectComplete() .verify(); assertThat(mockResponse.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(mockResponse.getHeaders().getAllow()).isEqualTo(Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS)); + assertThat(mockResponse.getHeaders().getAllow()).isEqualTo(Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS, HttpMethod.QUERY)); StepVerifier.create(mockResponse.getBody()).expectComplete().verify(); } 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 bbb0b7c764..85e2e4f2a9 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 @@ -377,7 +377,7 @@ class RequestMappingInfoHandlerMappingTests { .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,9 +395,15 @@ class RequestMappingInfoHandlerMappingTests { 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 testMediaTypeNotAcceptable(String url) { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/GlobalCorsConfigIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/GlobalCorsConfigIntegrationTests.java index 002feb2aa1..ce4a78c6cf 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/GlobalCorsConfigIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/GlobalCorsConfigIntegrationTests.java @@ -117,7 +117,7 @@ class GlobalCorsConfigIntegrationTests extends AbstractRequestMappingIntegration assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(entity.getHeaders().getAccessControlAllowOrigin()).isEqualTo("*"); assertThat(entity.getHeaders().getAccessControlAllowMethods()) - .containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST); + .containsExactly(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.POST); } @ParameterizedHttpServerTest diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/AbstractServerResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/AbstractServerResponse.java index d2288fc3f3..db6b8aa34f 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/AbstractServerResponse.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/AbstractServerResponse.java @@ -43,7 +43,7 @@ import org.springframework.web.servlet.ModelAndView; */ abstract class AbstractServerResponse extends ErrorHandlingServerResponse { - private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD); private final HttpStatusCode statusCode; diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java index 6333b87c78..4bf0fd99e9 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java @@ -265,6 +265,18 @@ public abstract class RequestPredicates { return method(HttpMethod.OPTIONS).and(path(pattern)); } + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code QUERY} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is QUERY and if the given pattern + * matches against the request path + * @see org.springframework.web.util.pattern.PathPattern + */ + public static RequestPredicate QUERY(String pattern) { + return method(HttpMethod.QUERY).and(path(pattern)); + } + /** * Return a {@code RequestPredicate} that matches if the request's path has the given extension. * @param extension the path extension to match against, ignoring case diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java index 86ccce7dec..c16d715ed7 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java @@ -41,7 +41,7 @@ import org.springframework.http.HttpStatus; class ResourceHandlerFunction implements HandlerFunction { private static final Set SUPPORTED_METHODS = - Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS); private final Resource resource; @@ -63,6 +63,11 @@ class ResourceHandlerFunction implements HandlerFunction { .headers(headers -> this.headersConsumer.accept(this.resource, headers)) .build(); } + else if (HttpMethod.QUERY.equals(method)) { + return EntityResponse.fromObject(this.resource) + .headers(headers -> this.headersConsumer.accept(this.resource, headers)) + .build(); + } else if (HttpMethod.HEAD.equals(method)) { Resource headResource = new HeadMethodResource(this.resource); return EntityResponse.fromObject(headResource) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java index 6a5c4806b7..7ce41621ec 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java @@ -229,6 +229,30 @@ class RouterFunctionBuilder implements RouterFunctions.Builder { return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction); } + // QUERY + + @Override + public RouterFunctions.Builder QUERY(HandlerFunction handlerFunction) { + return add(RequestPredicates.method(HttpMethod.QUERY), handlerFunction); + } + + @Override + public RouterFunctions.Builder QUERY(RequestPredicate predicate, HandlerFunction handlerFunction) { + return add(RequestPredicates.method(HttpMethod.QUERY).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder QUERY(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.QUERY(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder QUERY(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.QUERY(pattern).and(predicate), handlerFunction); + } + // other @Override diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java index f2ea64ea97..0486701785 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java @@ -610,6 +610,57 @@ public abstract class RouterFunctions { */ Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + /** + * Adds a route to the given handler function that handles HTTP {@code QUERY} requests. + * @param handlerFunction the handler function to handle all {@code QUERY} requests + * @return this builder + * @since x.x.x + */ + Builder QUERY(HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code QUERY} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code QUERY} requests that + * match {@code pattern} + * @return this builder + * @see org.springframework.web.util.pattern.PathPattern + */ + Builder QUERY(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code QUERY} requests + * that match the given predicate. + * @param predicate predicate to match + * @param handlerFunction the handler function to handle all {@code QUERY} requests that + * match {@code predicate} + * @return this builder + * @since x.x.x + * @see RequestPredicates + */ + Builder QUERY(RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code QUERY} requests + * that match the given pattern and predicate. + *

    For instance, the following example routes QUERY requests for "/user" that contain JSON + * to the {@code addUser} method in {@code userController}: + *

    +		 * RouterFunction<ServerResponse> route =
    +		 *   RouterFunctions.route()
    +		 *     .QUERY("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::addUser)
    +		 *     .build();
    +		 * 
    + * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code QUERY} requests that + * match {@code pattern} + * @return this builder + * @see org.springframework.web.util.pattern.PathPattern + */ + Builder QUERY(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + /** * Adds a route to the given handler function that handles all requests that match the * given predicate. diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/RequestMethodsRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/RequestMethodsRequestCondition.java index a3d8e0f42f..09eb0c23cb 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/RequestMethodsRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/RequestMethodsRequestCondition.java @@ -162,6 +162,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.GET)) { return requestMethodConditionCache.get(HttpMethod.GET.name()); } + if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.QUERY)) { + return requestMethodConditionCache.get(HttpMethod.QUERY.name()); + } } return null; } @@ -189,6 +192,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) { return 1; } + else if (this.methods.contains(RequestMethod.QUERY) && other.methods.contains(RequestMethod.HEAD)) { + return 1; + } } return 0; } 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 03c12463ba..9512b1e9e6 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 @@ -519,7 +519,7 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe 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); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java index d0a67717dd..0afe5bf30a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java @@ -243,7 +243,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro outputMessage.getServletResponse().setStatus(returnStatus.value()); if (returnStatus.value() == HttpStatus.OK.value()) { HttpMethod method = inputMessage.getMethod(); - if ((HttpMethod.GET.equals(method) || HttpMethod.HEAD.equals(method)) && + if ((HttpMethod.GET.equals(method) || HttpMethod.QUERY.equals(method) || HttpMethod.HEAD.equals(method)) && isResourceNotModified(inputMessage, outputMessage)) { outputMessage.flush(); return; @@ -292,7 +292,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro HttpHeaders responseHeaders = response.getHeaders(); String etag = responseHeaders.getETag(); long lastModifiedTimestamp = responseHeaders.getLastModified(); - if (request.getMethod() == HttpMethod.GET || request.getMethod() == HttpMethod.HEAD) { + if (request.getMethod() == HttpMethod.GET || request.getMethod() == HttpMethod.QUERY || request.getMethod() == HttpMethod.HEAD) { responseHeaders.remove(HttpHeaders.ETAG); responseHeaders.remove(HttpHeaders.LAST_MODIFIED); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java index bb971e13f1..c1c62f627e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java @@ -141,7 +141,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator public ResourceHttpRequestHandler() { - super(HttpMethod.GET.name(), HttpMethod.HEAD.name()); + super(HttpMethod.GET.name(), HttpMethod.QUERY.name(), HttpMethod.HEAD.name()); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java index c71d67550e..8faa0fcf7c 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java @@ -931,7 +931,7 @@ public class MvcNamespaceTests { CorsConfiguration config = configs.get("/**"); assertThat(config).isNotNull(); assertThat(config.getAllowedOrigins().toArray()).isEqualTo(new String[]{"*"}); - assertThat(config.getAllowedMethods().toArray()).isEqualTo(new String[]{"GET", "HEAD", "POST"}); + assertThat(config.getAllowedMethods().toArray()).isEqualTo(new String[]{"GET", "QUERY", "HEAD", "POST"}); assertThat(config.getAllowedHeaders().toArray()).isEqualTo(new String[]{"*"}); assertThat(config.getExposedHeaders()).isNull(); assertThat(config.getAllowCredentials()).isNull(); @@ -964,7 +964,7 @@ public class MvcNamespaceTests { assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123)); config = configs.get("/resources/**"); assertThat(config.getAllowedOrigins().toArray()).isEqualTo(new String[]{"https://domain1.com"}); - assertThat(config.getAllowedMethods().toArray()).isEqualTo(new String[]{"GET", "HEAD", "POST"}); + assertThat(config.getAllowedMethods().toArray()).isEqualTo(new String[]{"GET", "QUERY", "HEAD", "POST"}); assertThat(config.getAllowedHeaders().toArray()).isEqualTo(new String[]{"*"}); assertThat(config.getExposedHeaders()).isNull(); assertThat(config.getAllowCredentials()).isNull(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/ResourceHandlerFunctionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/ResourceHandlerFunctionTests.java index 781bad421c..b8a8f23a06 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/ResourceHandlerFunctionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/ResourceHandlerFunctionTests.java @@ -175,7 +175,7 @@ class ResourceHandlerFunctionTests { ServerResponse response = this.handlerFunction.handle(request); assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.headers().getAllow()).isEqualTo(Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS)); + assertThat(response.headers().getAllow()).isEqualTo(Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS)); MockHttpServletResponse servletResponse = new MockHttpServletResponse(); ModelAndView mav = response.writeTo(servletRequest, servletResponse, this.context); @@ -184,7 +184,7 @@ class ResourceHandlerFunctionTests { assertThat(servletResponse.getStatus()).isEqualTo(200); String allowHeader = servletResponse.getHeader("Allow"); String[] methods = StringUtils.tokenizeToStringArray(allowHeader, ","); - assertThat(methods).containsExactlyInAnyOrder("GET","HEAD","OPTIONS"); + assertThat(methods).containsExactlyInAnyOrder("GET","QUERY","HEAD","OPTIONS"); byte[] actualBytes = servletResponse.getContentAsByteArray(); assertThat(actualBytes).isEmpty(); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java index 865c209e36..1b58432080 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java @@ -185,7 +185,7 @@ public class HandlerMethodMappingTests { assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("https://domain.com"); - assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isEqualTo("GET,HEAD"); + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isEqualTo("GET,QUERY,HEAD"); } @Test 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 1e1e1d7b75..5ee85b6ee9 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,10 @@ class RequestMappingInfoHandlerMappingTests { 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); } @PathPatternsParameterizedTest @@ -572,6 +573,11 @@ class RequestMappingInfoHandlerMappingTests { @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 0e92023a08..8b83796942 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 @@ -149,6 +149,23 @@ class ResponseEntityExceptionHandlerTests { 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)).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/resource/ResourceHttpRequestHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java index d796b37a4f..482786c353 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java @@ -114,7 +114,7 @@ class ResourceHttpRequestHandlerTests { this.handler.handleRequest(this.request, this.response); assertThat(this.response.getStatus()).isEqualTo(200); - assertThat(this.response.getHeader("Allow")).isEqualTo("GET,HEAD,OPTIONS"); + assertThat(this.response.getHeader("Allow")).isEqualTo("GET,QUERY,HEAD,OPTIONS"); } @Test 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 43efcda039..a28e1f69d1 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 @@ class WebContentGeneratorTests { @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 @@ class WebContentGeneratorTests { 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