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 d28816d52b30..06c28a0b5abf 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 static BaseBuilder options(String urlTemplate, @Nullable Object... uri 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 60b39d57e76b..84d05bd4b7be 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 @@ public RequestHeadersUriSpec options() { 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 b7018bcdda2f..c47a046fa1ff 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 9c8cfcc50d03..af3651fee683 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 MockMvcRequestBuilder options() { 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 abf479391090..f7560f302e79 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 static MockHttpServletRequestBuilder head(URI uri) { 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 5c09d17fa085..4fc0696ad62d 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 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.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 @@ void requestParameterFromMultiValueMap() { } @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 ee4d794e0e4c..499d757093f5 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 List getAcceptPatch() { return MediaType.parseMediaTypes(get(ACCEPT_PATCH)); } + /** + * Set the list of acceptable {@linkplain MediaType media types} for + * {@code QUERY} methods, as specified by the {@code Accept-Query} header. + * @since 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 95debcfbcaa2..390188e0b4fc 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 @@ private HttpMethod(String name) { * Returns an array containing the standard HTTP methods. Specifically, * this method returns an array containing {@link #GET}, {@link #HEAD}, * {@link #POST}, {@link #PUT}, {@link #PATCH}, {@link #DELETE}, - * {@link #OPTIONS}, and {@link #TRACE}. + * {@link #OPTIONS}, {@link #TRACE}, and {@link #QUERY}. * *

Note that the returned value does not include any HTTP methods defined * in WebDav. @@ -124,6 +130,7 @@ public static HttpMethod valueOf(String method) { case "DELETE" -> DELETE; case "OPTIONS" -> OPTIONS; case "TRACE" -> TRACE; + case "QUERY" -> QUERY; default -> new HttpMethod(method); }; } diff --git a/spring-web/src/main/java/org/springframework/http/RequestEntity.java b/spring-web/src/main/java/org/springframework/http/RequestEntity.java index 64fc4316f05d..f8847fe814f7 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 static BodyBuilder post(String uriTemplate, @Nullable Object... uriVariab 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 d366eded4562..c82d608349b4 100644 --- a/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java @@ -32,6 +32,7 @@ import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpPut; import org.apache.hc.client5.http.classic.methods.HttpTrace; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; import org.apache.hc.client5.http.config.Configurable; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -345,6 +346,9 @@ else if (HttpMethod.OPTIONS.equals(httpMethod)) { else if (HttpMethod.TRACE.equals(httpMethod)) { return new HttpTrace(uri); } + else if (HttpMethod.QUERY.equals(httpMethod)) { + return new HttpUriRequestBase(HttpMethod.QUERY.name(), uri); + } throw new IllegalArgumentException("Invalid HTTP method: " + httpMethod); } diff --git a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java index bcba1c9953c1..3441e371e7e3 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java @@ -132,6 +132,9 @@ public HttpHeaders getHeaders() { if (HttpMethod.PATCH.equals(this.httpMethod)) { headers.setAcceptPatch(getSupportedMediaTypes()); } + 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 ef09e5124056..4a71e0f8ae1d 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 @@ * @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 5c1e962ac419..4135f865c84f 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 @@ * @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 1cce89e2545b..60902058c609 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 @@ * @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 8cfb36a6e389..943bac175015 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 @@ * @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 8cfc46101a93..86f551eb783f 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 @@ * @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 000000000000..42af68d8a0d6 --- /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 6cb0386e8482..663f51f5537d 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 @@ * 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 @@ * @see PutMapping * @see DeleteMapping * @see PatchMapping + * @see QueryMapping */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -121,7 +122,7 @@ /** * 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 b1f5438b9048..e1ca546ead43 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMethod.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMethod.java @@ -26,7 +26,7 @@ * {@link RequestMapping#method()} attribute of the {@link RequestMapping} annotation. * *

Note that, by default, {@link org.springframework.web.servlet.DispatcherServlet} - * supports GET, HEAD, POST, PUT, PATCH, and DELETE only. DispatcherServlet will + * supports GET, QUERY, HEAD, POST, PUT, PATCH, and DELETE only. DispatcherServlet will * process TRACE and OPTIONS with the default HttpServlet behavior unless explicitly * told to dispatch those request types as well: Check out the "dispatchOptionsRequest" * and "dispatchTraceRequest" properties, switching them to "true" if necessary. @@ -39,7 +39,7 @@ */ public enum RequestMethod { - GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE; + GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, QUERY; /** @@ -60,6 +60,7 @@ public enum RequestMethod { case "DELETE" -> DELETE; case "OPTIONS" -> OPTIONS; case "TRACE" -> TRACE; + case "QUERY" -> QUERY; default -> null; }; } @@ -92,6 +93,7 @@ public HttpMethod asHttpMethod() { case DELETE -> HttpMethod.DELETE; case OPTIONS -> HttpMethod.OPTIONS; case TRACE -> HttpMethod.TRACE; + case QUERY -> HttpMethod.QUERY; }; } diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index d39e43bd5f20..4db69a3f9b1a 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 @@ public RequestHeadersUriSpec options() { 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 9804bf55bab8..4b8b5ce2d9f0 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 6873edf5d665..c8e6049f9d4f 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 3564cc8629ca..0b826637e71c 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 @@ protected boolean isEligibleForEtag(HttpServletRequest request, HttpServletRespo 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 f38dd40b9501..14ca50d4b38e 100644 --- a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java @@ -161,6 +161,9 @@ public HttpHeaders getHeaders() { if (this.method == HttpMethod.PATCH) { headers.setAcceptPatch(this.supportedMediaTypes); } + 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 a3ec76997b46..6a61fcfd451a 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 @@ */ 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 745e59533d5b..2b12bed9eaa6 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 @@ *

  • {@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 000000000000..21e04047c4ce --- /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 61ebf19faa1a..6b291f1606de 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 055149ea53e1..eb5aff2f6975 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpMethodTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpMethodTests.java @@ -44,12 +44,12 @@ void comparison() { void values() { HttpMethod[] values = HttpMethod.values(); assertThat(values).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE); + HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE, HttpMethod.QUERY); // check defensive copy values[0] = HttpMethod.POST; assertThat(HttpMethod.values()).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE); + HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE, HttpMethod.QUERY); } @Test diff --git a/spring-web/src/test/java/org/springframework/web/bind/annotation/ControllerMappingReflectiveProcessorTests.java b/spring-web/src/test/java/org/springframework/web/bind/annotation/ControllerMappingReflectiveProcessorTests.java index b46afe89e954..f8969a3979b0 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 @@ Response get() { 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 @@ void postRawHttpEntity(HttpEntity entity) { 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 18b8815e9595..05384e732423 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 @@ void tearDown() throws Exception { 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 @@ private MockResponse putRequest(RecordedRequest request, String expectedRequestC 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 @@ else if (request.getPath().equals("/patch")) { 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 022309b30813..b8910210dc4d 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 @@ void optionsForAllow(ClientHttpRequestFactory clientHttpRequestFactory) { setUpClient(clientHttpRequestFactory); Set allowed = template.optionsForAllow(URI.create(baseUrl + "/get")); - assertThat(allowed).as("Invalid response").isEqualTo(Set.of(HttpMethod.GET, HttpMethod.OPTIONS, HttpMethod.HEAD, HttpMethod.TRACE)); + assertThat(allowed).as("Invalid response").isEqualTo(Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.OPTIONS, HttpMethod.HEAD, HttpMethod.TRACE)); } @ParameterizedRestTemplateTest diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java index 4c421a56bded..13b047918e94 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.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.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 @@ void postForEntityNull() throws Exception { 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 092781475f94..1f8fb86ea426 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 @@ void combineWithDefaultPermitValues() { 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 @@ void combineWithDefaultPermitValues() { 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 @@ void checkOriginPatternNotAllowed() { @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 @@ void changePermitDefaultValues() { 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 bcb4ae852365..b5add6057149 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 @@ void preflightRequestMatchedAllowedMethod() throws Exception { 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 988175990506..38253acdd0db 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 @@ void preflightRequestMatchedAllowedMethod() { 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 9bde5d3eab40..81bc715fcb48 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 static RequestMappingPredicate headMapping(String... path) { 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 f16d0c1a4637..49f025250942 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 @@ public RequestHeadersUriSpec options() { 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 1b07d1016517..f254a1864e2f 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 090f0450b02e..67151f2c001d 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 @@ public Mono render(String name, Map model) { */ 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 6ee7be2c2c21..d3697d997999 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 static RequestPredicate OPTIONS(String pattern) { 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 56ea5b1fb735..c82c9c13470f 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 @@ 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 @@ public Mono handle(ServerRequest request) { .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 e8133b2f5cc0..76bd64dcb18d 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 @@ public RouterFunctions.Builder OPTIONS(String pattern, RequestPredicate predicat 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 8331ceb0f5aa..ad1397f01cd7 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 interface Builder { */ 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 d108080a1082..487998146350 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 @@ */ 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 9289aee8fea0..4257c366f246 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 @@ else if (isEmpty()) { 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 @@ else if (this.methods.size() == 1) { 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 73e8146714fe..d1b345fa0a62 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 @@ protected void handleMatch(RequestMappingInfo info, HandlerMethod handlerMethod, HttpMethod httpMethod = request.getMethod(); Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.equals(httpMethod)) { - Set mediaTypes = helper.getConsumablePatchMediaTypes(); - HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); + Set patchMediaTypes = helper.getConsumablePatchMediaTypes(); + Set queryMediaTypes = helper.getConsumableQueryMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, patchMediaTypes, queryMediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } throw new MethodNotAllowedException(httpMethod, methods); @@ -323,14 +324,23 @@ public List>> getParamConditions() { * PATCH specified, or that have no methods at all. */ public Set getConsumablePatchMediaTypes() { - Set result = new LinkedHashSet<>(); - for (PartialMatch match : this.partialMatches) { - Set methods = match.getInfo().getMethodsCondition().getMethods(); - if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) { - result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); - } - } - return result; + return getConsumableMediaTypesForMethod(RequestMethod.PATCH); + } + + /** + * Return declared "consumable" types but only among those that have + * PATCH specified, or that have no methods at all. + */ + public Set getConsumableQueryMediaTypes() { + return getConsumableMediaTypesForMethod(RequestMethod.QUERY); + } + + private Set getConsumableMediaTypesForMethod(RequestMethod method) { + return this.partialMatches.stream() + .map(PartialMatch::getInfo) + .filter(info -> info.getMethodsCondition().getMethods().isEmpty() || info.getMethodsCondition().getMethods().contains(method)) + .flatMap(info -> info.getConsumesCondition().getConsumableMediaTypes().stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); } @@ -400,9 +410,10 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch, Set acceptQuery) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); + this.headers.setAcceptQuery(new ArrayList<>(acceptQuery)); } private static Set initAllowedHttpMethods(Set declaredMethods) { @@ -413,7 +424,7 @@ private static Set initAllowedHttpMethods(Set declaredMe } else { Set result = new LinkedHashSet<>(declaredMethods); - if (result.contains(HttpMethod.GET)) { + if (result.contains(HttpMethod.GET) || result.contains(HttpMethod.QUERY)) { result.add(HttpMethod.HEAD); } result.add(HttpMethod.OPTIONS); 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 1961429a1d66..077db094b444 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 @@ */ 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 8d270c98a378..141f60d0a982 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 @@ void options() { 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 @@ void options() { .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 bbb0b7c7643d..85e2e4f2a946 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 @@ private void testHttpMediaTypeNotSupportedException(String url) { .isEqualTo(Collections.singletonList(new MediaType("application", "xml")))); } - private void testHttpOptions(String requestURI, Set allowedMethods, @Nullable MediaType acceptPatch) { + private void testHttpOptions(String requestURI, Set allowedMethods, @Nullable MediaType acceptMediaType) { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI)); HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); @@ -395,9 +395,15 @@ private void testHttpOptions(String requestURI, Set allowedMethods, HttpHeaders headers = (HttpHeaders) value; assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); - if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { - assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); + if (acceptMediaType != null) { + if (headers.getAllow().contains(HttpMethod.PATCH)) { + assertThat(headers.getAcceptPatch()).containsExactly(acceptMediaType); + } + if (headers.getAllow().contains(HttpMethod.QUERY)) { + assertThat(headers.getAcceptQuery()).containsExactly(acceptMediaType); + } } + } private void 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 002feb2aa1af..ce4a78c6cf01 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 @@ void preFlightRequestWithCorsEnabled(HttpServer httpServer) throws Exception { 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 d2288fc3f3d2..db6b8aa34f12 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 @@ */ 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 6333b87c7870..4bf0fd99e98b 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 static RequestPredicate OPTIONS(String pattern) { 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 86ccce7dec01..c16d715ed714 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 @@ 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 @@ public ServerResponse handle(ServerRequest request) { .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 6a5c4806b731..7ce41621ec81 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 @@ public RouterFunctions.Builder OPTIONS(String pattern, RequestPredicate predicat 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 f2ea64ea97ab..048670178520 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 interface Builder { */ 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 a3d8e0f42f82..09eb0c23cb99 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 @@ else if (isEmpty()) { 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 @@ else if (this.methods.size() == 1) { 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 03c12463ba4f..9512b1e9e63d 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 @@ private static Set initAllowedHttpMethods(Set declaredMethod for (String method : declaredMethods) { HttpMethod httpMethod = HttpMethod.valueOf(method); result.add(httpMethod); - if (httpMethod == HttpMethod.GET) { + if (httpMethod == HttpMethod.GET || httpMethod == HttpMethod.QUERY) { result.add(HttpMethod.HEAD); } } 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 d0a67717dded..0afe5bf30af2 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 @@ else if (returnValue instanceof ProblemDetail detail) { 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 @@ private boolean isResourceNotModified(ServletServerHttpRequest request, ServletS 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 bb971e13f1a7..c1c62f627eb4 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 c71d67550e26..8faa0fcf7ce7 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 @@ void testCorsMinimal() { 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 @@ void testCors() { 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 781bad421ceb..b8a8f23a0668 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 @@ void options() throws ServletException, IOException { 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 @@ void options() throws ServletException, IOException { 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 865c209e3605..1b584320807b 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 @@ void abortInterceptorInPreFlightRequestWithCorsConfig() throws Exception { 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 1e1e1d7b7594..5ee85b6ee91b 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 @@ void getHandlerMediaTypeNotSupportedWithParseError(TestRequestMappingInfoHandler void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception { testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS", null); testHttpOptions(mapping, "/person/1", "PUT,OPTIONS", null); - testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", null); + testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY", null); testHttpOptions(mapping, "/something", "PUT,POST", null); testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar")); + testHttpOptions(mapping, "/quid", "QUERY,HEAD,OPTIONS", null); } @PathPatternsParameterizedTest @@ -572,6 +573,11 @@ public String getBaz() { @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") public void patchBaz(String value) { } + + @RequestMapping(value = "/quid", method = RequestMethod.QUERY, consumes = "application/json", produces = "application/json") + public String query(@RequestBody String body) { + return "{}"; + } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java index 0e92023a088b..8b83796942b4 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 @@ void patchHttpMediaTypeNotSupported() { assertThat(headers.getFirst(HttpHeaders.ACCEPT_PATCH)).isEqualTo("application/atom+xml, application/xml"); } + @Test + void queryHttpMediaTypeNotSupported() { + this.servletRequest = new MockHttpServletRequest("QUERY", "/"); + this.request = new ServletWebRequest(this.servletRequest, this.servletResponse); + + ResponseEntity entity = testException( + new HttpMediaTypeNotSupportedException( + MediaType.APPLICATION_JSON, + List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML), + HttpMethod.QUERY)); + + HttpHeaders headers = entity.getHeaders(); + assertThat(headers.getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/atom+xml, application/xml"); + assertThat(headers.getFirst(HttpHeaders.ACCEPT)).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 d796b37a4fc5..482786c35330 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 @@ void supportsOptionsRequests() throws Exception { 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 43efcda039d8..a28e1f69d19b 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java @@ -39,7 +39,7 @@ void getAllowHeaderWithConstructorTrue() { @Test void getAllowHeaderWithConstructorFalse() { WebContentGenerator generator = new TestWebContentGenerator(false); - assertThat(generator.getAllowHeader()).isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); + assertThat(generator.getAllowHeader()).isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY"); } @Test @@ -59,7 +59,7 @@ void getAllowHeaderWithSupportedMethodsSetter() { void getAllowHeaderWithSupportedMethodsSetterEmpty() { WebContentGenerator generator = new TestWebContentGenerator(); generator.setSupportedMethods(); - assertThat(generator.getAllowHeader()).as("Effectively \"no restriction\" on supported methods").isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); + assertThat(generator.getAllowHeader()).as("Effectively \"no restriction\" on supported methods").isEqualTo("GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,QUERY"); } @Test