Add QUERY HTTP method

Signed-off-by: Mario Daniel Ruiz Saavedra <desiderantes93@gmail.com>
This commit is contained in:
Mario Daniel Ruiz Saavedra 2025-06-03 19:27:40 -03:00
parent b699b65b40
commit 6f9aa37b79
67 changed files with 752 additions and 71 deletions

View File

@ -184,6 +184,16 @@ public final class MockServerHttpRequest extends AbstractServerHttpRequest {
return method(HttpMethod.OPTIONS, urlTemplate, uriVars); 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}. * Create a builder with the given HTTP method and a {@link URI}.
* @param method the HTTP method (GET, POST, etc) * @param method the HTTP method (GET, POST, etc)

View File

@ -165,6 +165,11 @@ class DefaultWebTestClient implements WebTestClient {
return methodInternal(HttpMethod.OPTIONS); return methodInternal(HttpMethod.OPTIONS);
} }
@Override
public RequestBodyUriSpec query() {
return methodInternal(HttpMethod.QUERY);
}
@Override @Override
public RequestBodyUriSpec method(HttpMethod httpMethod) { public RequestBodyUriSpec method(HttpMethod httpMethod) {
return methodInternal(httpMethod); return methodInternal(httpMethod);

View File

@ -146,6 +146,12 @@ public interface WebTestClient {
*/ */
RequestHeadersUriSpec<?> options(); 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}. * Prepare a request for the specified {@code HttpMethod}.
* @return a spec for specifying the target URL * @return a spec for specifying the target URL

View File

@ -333,6 +333,20 @@ public final class MockMvcTester {
return method(HttpMethod.OPTIONS); return method(HttpMethod.OPTIONS);
} }
/**
* Prepare an HTTP QUERY request.
* <p>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}. * Prepare a request for the specified {@code HttpMethod}.
* <p>The returned builder can be wrapped in {@code assertThat} to enable * <p>The returned builder can be wrapped in {@code assertThat} to enable

View File

@ -175,6 +175,24 @@ public abstract class MockMvcRequestBuilders {
return new MockHttpServletRequestBuilder(HttpMethod.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. * Create a {@link MockHttpServletRequestBuilder} for a request with the given HTTP method.
* @param method the HTTP method (GET, POST, etc.) * @param method the HTTP method (GET, POST, etc.)

View File

@ -32,6 +32,7 @@ import jakarta.servlet.http.Cookie;
import org.assertj.core.api.ThrowingConsumer; import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; 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.assertj.core.api.Assertions.entry;
import static org.springframework.http.HttpMethod.GET; import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.QUERY;
/** /**
* Tests for building a {@link MockHttpServletRequest} with * Tests for building a {@link MockHttpServletRequest} with
@ -391,17 +393,20 @@ class MockHttpServletRequestBuilderTests {
} }
@Test @Test
@ParameterizedTest()
void requestParameterFromRequestBodyFormData() { void requestParameterFromRequestBodyFormData() {
String contentType = "application/x-www-form-urlencoded;charset=UTF-8"; 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"; String body = "name+1=value+1&name+2=value+A&name+2=value+B&name+3";
MockHttpServletRequest request = new MockHttpServletRequestBuilder(POST).uri("/foo") for (HttpMethod method : List.of(POST, QUERY)) {
.contentType(contentType).content(body.getBytes(UTF_8)) MockHttpServletRequest request = new MockHttpServletRequestBuilder(method).uri("/foo")
.buildRequest(this.servletContext); .contentType(contentType).content(body.getBytes(UTF_8))
.buildRequest(this.servletContext);
assertThat(request.getParameterMap().get("name 1")).containsExactly("value 1"); 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 2")).containsExactly("value A", "value B");
assertThat(request.getParameterMap().get("name 3")).containsExactly((String) null); assertThat(request.getParameterMap().get("name 3")).containsExactly((String) null);
}
} }
@Test @Test

View File

@ -130,6 +130,12 @@ public class HttpHeaders implements Serializable {
* @see <a href="https://tools.ietf.org/html/rfc7233#section-2.3">Section 5.3.5 of RFC 7233</a> * @see <a href="https://tools.ietf.org/html/rfc7233#section-2.3">Section 5.3.5 of RFC 7233</a>
*/ */
public static final String ACCEPT_RANGES = "Accept-Ranges"; public static final String ACCEPT_RANGES = "Accept-Ranges";
/**
* The HTTP {@code Accept-Query} header field name.
* @see <a href="https://httpwg.org/http-extensions/draft-ietf-httpbis-safe-method-w-body.html">IETF Draft</a>
*/
public static final String ACCEPT_QUERY = "Accept-Query";
/** /**
* The CORS {@code Access-Control-Allow-Credentials} response header field name. * The CORS {@code Access-Control-Allow-Credentials} response header field name.
* @see <a href="https://www.w3.org/TR/cors/">CORS W3C recommendation</a> * @see <a href="https://www.w3.org/TR/cors/">CORS W3C recommendation</a>
@ -635,6 +641,27 @@ public class HttpHeaders implements Serializable {
return MediaType.parseMediaTypes(get(ACCEPT_PATCH)); 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<MediaType> 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.
* <p>Returns an empty list when the acceptable media types are unspecified.
* @since x.x.x
*/
public List<MediaType> getAcceptQuery() {
return MediaType.parseMediaTypes(get(ACCEPT_QUERY));
}
/** /**
* Set the (new) value of the {@code Access-Control-Allow-Credentials} response header. * Set the (new) value of the {@code Access-Control-Allow-Credentials} response header.
*/ */

View File

@ -37,25 +37,25 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
/** /**
* The HTTP method {@code GET}. * The HTTP method {@code GET}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.3">HTTP 1.1, section 9.3</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.1">HTTP Semantics, section 9.3.1</a>
*/ */
public static final HttpMethod GET = new HttpMethod("GET"); public static final HttpMethod GET = new HttpMethod("GET");
/** /**
* The HTTP method {@code HEAD}. * The HTTP method {@code HEAD}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4">HTTP 1.1, section 9.4</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.2">HTTP Semantics, section 9.3.2</a>
*/ */
public static final HttpMethod HEAD = new HttpMethod("HEAD"); public static final HttpMethod HEAD = new HttpMethod("HEAD");
/** /**
* The HTTP method {@code POST}. * The HTTP method {@code POST}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.5">HTTP 1.1, section 9.5</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.3">HTTP Semantics, section 9.3.3</a>
*/ */
public static final HttpMethod POST = new HttpMethod("POST"); public static final HttpMethod POST = new HttpMethod("POST");
/** /**
* The HTTP method {@code PUT}. * The HTTP method {@code PUT}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6">HTTP 1.1, section 9.6</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.4">HTTP Semantics, section 9.3.4</a>
*/ */
public static final HttpMethod PUT = new HttpMethod("PUT"); public static final HttpMethod PUT = new HttpMethod("PUT");
@ -67,23 +67,29 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
/** /**
* The HTTP method {@code DELETE}. * The HTTP method {@code DELETE}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.7">HTTP 1.1, section 9.7</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.5">HTTP Semantics, section 9.3.5</a>
*/ */
public static final HttpMethod DELETE = new HttpMethod("DELETE"); public static final HttpMethod DELETE = new HttpMethod("DELETE");
/** /**
* The HTTP method {@code OPTIONS}. * The HTTP method {@code OPTIONS}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.2">HTTP 1.1, section 9.2</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.7">HTTP Semantics, section 9.3.7</a>
*/ */
public static final HttpMethod OPTIONS = new HttpMethod("OPTIONS"); public static final HttpMethod OPTIONS = new HttpMethod("OPTIONS");
/** /**
* The HTTP method {@code TRACE}. * The HTTP method {@code TRACE}.
* @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.8">HTTP 1.1, section 9.8</a> * @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.8">HTTP Semantics, section 9.3.8</a>
*/ */
public static final HttpMethod TRACE = new HttpMethod("TRACE"); 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 <a href="https://httpwg.org/http-extensions/draft-ietf-httpbis-safe-method-w-body.html">IETF Draft</a>
*/
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; private final String name;
@ -97,7 +103,7 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
* Returns an array containing the standard HTTP methods. Specifically, * Returns an array containing the standard HTTP methods. Specifically,
* this method returns an array containing {@link #GET}, {@link #HEAD}, * this method returns an array containing {@link #GET}, {@link #HEAD},
* {@link #POST}, {@link #PUT}, {@link #PATCH}, {@link #DELETE}, * {@link #POST}, {@link #PUT}, {@link #PATCH}, {@link #DELETE},
* {@link #OPTIONS}, and {@link #TRACE}. * {@link #OPTIONS}, {@link #TRACE}, and {@link #QUERY}.
* *
* <p>Note that the returned value does not include any HTTP methods defined * <p>Note that the returned value does not include any HTTP methods defined
* in WebDav. * in WebDav.
@ -124,6 +130,7 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
case "DELETE" -> DELETE; case "DELETE" -> DELETE;
case "OPTIONS" -> OPTIONS; case "OPTIONS" -> OPTIONS;
case "TRACE" -> TRACE; case "TRACE" -> TRACE;
case "QUERY" -> QUERY;
default -> new HttpMethod(method); default -> new HttpMethod(method);
}; };
} }

View File

@ -382,6 +382,26 @@ public class RequestEntity<T> extends HttpEntity<T> {
return method(HttpMethod.POST, uriTemplate, uriVariables); 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. * Create an HTTP PUT builder with the given url.
* @param url the URL * @param url the URL

View File

@ -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.HttpPost;
import org.apache.hc.client5.http.classic.methods.HttpPut; 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.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.Configurable;
import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.classic.HttpClients;
@ -345,6 +346,9 @@ public class HttpComponentsClientHttpRequestFactory implements ClientHttpRequest
else if (HttpMethod.TRACE.equals(httpMethod)) { else if (HttpMethod.TRACE.equals(httpMethod)) {
return new HttpTrace(uri); return new HttpTrace(uri);
} }
else if (HttpMethod.QUERY.equals(httpMethod)) {
return new HttpUriRequestBase(HttpMethod.QUERY.name(), uri);
}
throw new IllegalArgumentException("Invalid HTTP method: " + httpMethod); throw new IllegalArgumentException("Invalid HTTP method: " + httpMethod);
} }

View File

@ -132,6 +132,9 @@ public class HttpMediaTypeNotSupportedException extends HttpMediaTypeException {
if (HttpMethod.PATCH.equals(this.httpMethod)) { if (HttpMethod.PATCH.equals(this.httpMethod)) {
headers.setAcceptPatch(getSupportedMediaTypes()); headers.setAcceptPatch(getSupportedMediaTypes());
} }
if (HttpMethod.QUERY.equals(this.httpMethod)) {
headers.setAcceptQuery(getSupportedMediaTypes());
}
return headers; return headers;
} }

View File

@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor;
* @see PostMapping * @see PostMapping
* @see PutMapping * @see PutMapping
* @see PatchMapping * @see PatchMapping
* @see QueryMapping
* @see RequestMapping * @see RequestMapping
*/ */
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor;
* @see PutMapping * @see PutMapping
* @see DeleteMapping * @see DeleteMapping
* @see PatchMapping * @see PatchMapping
* @see QueryMapping
* @see RequestMapping * @see RequestMapping
*/ */
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor;
* @see PostMapping * @see PostMapping
* @see PutMapping * @see PutMapping
* @see DeleteMapping * @see DeleteMapping
* @see QueryMapping
* @see RequestMapping * @see RequestMapping
*/ */
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor;
* @see PutMapping * @see PutMapping
* @see DeleteMapping * @see DeleteMapping
* @see PatchMapping * @see PatchMapping
* @see QueryMapping
* @see RequestMapping * @see RequestMapping
*/ */
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@ -44,6 +44,7 @@ import org.springframework.core.annotation.AliasFor;
* @see PostMapping * @see PostMapping
* @see DeleteMapping * @see DeleteMapping
* @see PatchMapping * @see PatchMapping
* @see QueryMapping
* @see RequestMapping * @see RequestMapping
*/ */
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@ -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.
*
* <p>Specifically, {@code @QueryMapping} is a <em>composed annotation</em> that
* acts as a shortcut for {@code @RequestMapping(method = RequestMethod.QUERY)}.
*
* <p><strong>NOTE:</strong> 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 "";
}

View File

@ -51,8 +51,8 @@ import org.springframework.core.annotation.AliasFor;
* at the method level. In most cases, at the method level applications will * at the method level. In most cases, at the method level applications will
* prefer to use one of the HTTP method specific variants * prefer to use one of the HTTP method specific variants
* {@link GetMapping @GetMapping}, {@link PostMapping @PostMapping}, * {@link GetMapping @GetMapping}, {@link PostMapping @PostMapping},
* {@link PutMapping @PutMapping}, {@link DeleteMapping @DeleteMapping}, or * {@link PutMapping @PutMapping}, {@link DeleteMapping @DeleteMapping},
* {@link PatchMapping @PatchMapping}. * {@link PatchMapping @PatchMapping}, or {@link QueryMapping}.
* *
* <p><strong>NOTE:</strong> This annotation cannot be used in conjunction with * <p><strong>NOTE:</strong> This annotation cannot be used in conjunction with
* other {@code @RequestMapping} annotations that are declared on the same element * other {@code @RequestMapping} annotations that are declared on the same element
@ -75,6 +75,7 @@ import org.springframework.core.annotation.AliasFor;
* @see PutMapping * @see PutMapping
* @see DeleteMapping * @see DeleteMapping
* @see PatchMapping * @see PatchMapping
* @see QueryMapping
*/ */
@Target({ElementType.TYPE, ElementType.METHOD}) @Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@ -121,7 +122,7 @@ public @interface RequestMapping {
/** /**
* The HTTP request methods to map to, narrowing the primary mapping: * 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.
* <p><b>Supported at the type level as well as at the method level!</b> * <p><b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings inherit this * When used at the type level, all method-level mappings inherit this
* HTTP method restriction. * HTTP method restriction.

View File

@ -26,7 +26,7 @@ import org.springframework.util.Assert;
* {@link RequestMapping#method()} attribute of the {@link RequestMapping} annotation. * {@link RequestMapping#method()} attribute of the {@link RequestMapping} annotation.
* *
* <p>Note that, by default, {@link org.springframework.web.servlet.DispatcherServlet} * <p>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 * process TRACE and OPTIONS with the default HttpServlet behavior unless explicitly
* told to dispatch those request types as well: Check out the "dispatchOptionsRequest" * told to dispatch those request types as well: Check out the "dispatchOptionsRequest"
* and "dispatchTraceRequest" properties, switching them to "true" if necessary. * and "dispatchTraceRequest" properties, switching them to "true" if necessary.
@ -39,7 +39,7 @@ import org.springframework.util.Assert;
*/ */
public enum RequestMethod { 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 "DELETE" -> DELETE;
case "OPTIONS" -> OPTIONS; case "OPTIONS" -> OPTIONS;
case "TRACE" -> TRACE; case "TRACE" -> TRACE;
case "QUERY" -> QUERY;
default -> null; default -> null;
}; };
} }
@ -92,6 +93,7 @@ public enum RequestMethod {
case DELETE -> HttpMethod.DELETE; case DELETE -> HttpMethod.DELETE;
case OPTIONS -> HttpMethod.OPTIONS; case OPTIONS -> HttpMethod.OPTIONS;
case TRACE -> HttpMethod.TRACE; case TRACE -> HttpMethod.TRACE;
case QUERY -> HttpMethod.QUERY;
}; };
} }

View File

@ -193,6 +193,11 @@ final class DefaultRestClient implements RestClient {
return methodInternal(HttpMethod.OPTIONS); return methodInternal(HttpMethod.OPTIONS);
} }
@Override
public RequestHeadersUriSpec<?> query() {
return methodInternal(HttpMethod.QUERY);
}
@Override @Override
public RequestBodyUriSpec method(HttpMethod method) { public RequestBodyUriSpec method(HttpMethod method) {
Assert.notNull(method, "HttpMethod must not be null"); Assert.notNull(method, "HttpMethod must not be null");

View File

@ -123,6 +123,12 @@ public interface RestClient {
*/ */
RequestHeadersUriSpec<?> options(); 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}. * Start building a request for the given {@code HttpMethod}.
* @return a spec for specifying the target URL * @return a spec for specifying the target URL

View File

@ -63,9 +63,9 @@ public class CorsConfiguration {
private static final List<String> DEFAULT_PERMIT_ALL = Collections.singletonList(ALL); private static final List<String> DEFAULT_PERMIT_ALL = Collections.singletonList(ALL);
private static final List<HttpMethod> DEFAULT_METHODS = List.of(HttpMethod.GET, HttpMethod.HEAD); private static final List<HttpMethod> DEFAULT_METHODS = List.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD);
private static final List<String> DEFAULT_PERMIT_METHODS = List.of(HttpMethod.GET.name(), private static final List<String> DEFAULT_PERMIT_METHODS = List.of(HttpMethod.GET.name(), HttpMethod.QUERY.name(),
HttpMethod.HEAD.name(), HttpMethod.POST.name()); HttpMethod.HEAD.name(), HttpMethod.POST.name());

View File

@ -151,7 +151,7 @@ public class ShallowEtagHeaderFilter extends OncePerRequestFilter {
if (!response.isCommitted() && if (!response.isCommitted() &&
responseStatusCode >= 200 && responseStatusCode < 300 && 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); String cacheControl = response.getHeader(HttpHeaders.CACHE_CONTROL);
return (cacheControl == null || !cacheControl.contains(DIRECTIVE_NO_STORE)); return (cacheControl == null || !cacheControl.contains(DIRECTIVE_NO_STORE));

View File

@ -161,6 +161,9 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException
if (this.method == HttpMethod.PATCH) { if (this.method == HttpMethod.PATCH) {
headers.setAcceptPatch(this.supportedMediaTypes); headers.setAcceptPatch(this.supportedMediaTypes);
} }
if (this.method == HttpMethod.QUERY) {
headers.setAcceptQuery(this.supportedMediaTypes);
}
return headers; return headers;
} }

View File

@ -66,7 +66,7 @@ import org.springframework.web.server.session.WebSessionManager;
*/ */
public class DefaultServerWebExchange implements ServerWebExchange { public class DefaultServerWebExchange implements ServerWebExchange {
private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD);
private static final ResolvableType FORM_DATA_TYPE = private static final ResolvableType FORM_DATA_TYPE =
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class);

View File

@ -51,6 +51,7 @@ import org.springframework.web.util.UriBuilderFactory;
* <li>{@link PutExchange} * <li>{@link PutExchange}
* <li>{@link PatchExchange} * <li>{@link PatchExchange}
* <li>{@link DeleteExchange} * <li>{@link DeleteExchange}
* <li>{@link QueryExchange}
* </ul> * </ul>
* *
* <p>Supported method arguments: * <p>Supported method arguments:

View File

@ -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 "";
}

View File

@ -18,9 +18,11 @@ package org.springframework.web.client
import org.springframework.core.ParameterizedTypeReference import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.RequestEntity import org.springframework.http.RequestEntity
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.client.queryForEntity
import java.lang.Class import java.lang.Class
import java.net.URI import java.net.URI
import kotlin.reflect.KClass import kotlin.reflect.KClass
@ -291,3 +293,45 @@ inline fun <reified T : Any> RestOperations.exchange(url: URI, method: HttpMetho
@Throws(RestClientException::class) @Throws(RestClientException::class)
inline fun <reified T : Any> RestOperations.exchange(requestEntity: RequestEntity<*>): ResponseEntity<T> = inline fun <reified T : Any> RestOperations.exchange(requestEntity: RequestEntity<*>): ResponseEntity<T> =
exchange(requestEntity, object : ParameterizedTypeReference<T>() {}) exchange(requestEntity, object : ParameterizedTypeReference<T>() {})
/**
* Extension for [RestOperations.postForEntity] providing a `postForEntity<Foo>(...)`
* 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 <reified T : Any> RestOperations.queryForEntity(url: String, request: Any? = null,
vararg uriVariables: Any?): ResponseEntity<T> =
exchange(url = url, method = HttpMethod.QUERY, requestEntity = HttpEntity(request, null as (HttpHeaders?) ), uriVariables= uriVariables)
/**
* Extension for [RestOperations.postForEntity] providing a `postForEntity<Foo>(...)`
* 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 <reified T : Any> RestOperations.queryForEntity(url: String, request: Any? = null,
uriVariables: Map<String, *>): ResponseEntity<T> =
exchange(url = url, method = HttpMethod.QUERY, requestEntity = HttpEntity(request, null as (HttpHeaders?) ), uriVariables= uriVariables)
/**
* Extension for [RestOperations.postForEntity] providing a `postForEntity<Foo>(...)`
* 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 <reified T: Any> RestOperations.queryForEntity(url: URI, request: Any? = null): ResponseEntity<T> =
exchange(url = url, method = HttpMethod.QUERY, requestEntity = HttpEntity(request, null as (HttpHeaders?) ))

View File

@ -44,12 +44,12 @@ class HttpMethodTests {
void values() { void values() {
HttpMethod[] values = HttpMethod.values(); HttpMethod[] values = HttpMethod.values();
assertThat(values).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, 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 // check defensive copy
values[0] = HttpMethod.POST; values[0] = HttpMethod.POST;
assertThat(HttpMethod.values()).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, 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 @Test

View File

@ -213,6 +213,11 @@ class ControllerMappingReflectiveProcessorTests {
void post(@RequestBody Request request) { void post(@RequestBody Request request) {
} }
@QueryMapping
Response query(@RequestBody Request request) {
return new Response("response");
}
@PostMapping @PostMapping
void postForm(@ModelAttribute Request request) { void postForm(@ModelAttribute Request request) {
} }
@ -247,6 +252,17 @@ class ControllerMappingReflectiveProcessorTests {
void postPartToConvert(@RequestPart Request request) { void postPartToConvert(@RequestPart Request request) {
} }
@QueryMapping
HttpEntity<Response> querytHttpEntity(HttpEntity<Request> entity) {
return new HttpEntity<>(new Response("response"));
}
@QueryMapping
@SuppressWarnings("rawtypes")
HttpEntity queryRawHttpEntity(HttpEntity entity) {
return new HttpEntity(new Response("response"));
}
} }
@RestController @RestController

View File

@ -74,7 +74,7 @@ abstract class AbstractMockWebServerTests {
private MockResponse getRequest(RecordedRequest request, byte[] body, String contentType) { private MockResponse getRequest(RecordedRequest request, byte[] body, String contentType) {
if (request.getMethod().equals("OPTIONS")) { 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(); Buffer buf = new Buffer();
buf.write(body); buf.write(body);
@ -231,6 +231,28 @@ abstract class AbstractMockWebServerTests {
return new MockResponse().setResponseCode(202); 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 { protected class TestDispatcher extends Dispatcher {
@ -293,6 +315,9 @@ abstract class AbstractMockWebServerTests {
else if (request.getPath().equals("/put")) { else if (request.getPath().equals("/put")) {
return putRequest(request, helloWorld); return putRequest(request, helloWorld);
} }
else if (request.getPath().equals("/query")) {
return queryRequest(request, helloWorld, textContentType.toString(), helloWorldBytes);
}
return new MockResponse().setResponseCode(404); return new MockResponse().setResponseCode(404);
} }
catch (Throwable ex) { catch (Throwable ex) {

View File

@ -290,7 +290,7 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests {
setUpClient(clientHttpRequestFactory); setUpClient(clientHttpRequestFactory);
Set<HttpMethod> allowed = template.optionsForAllow(URI.create(baseUrl + "/get")); Set<HttpMethod> 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 @ParameterizedRestTemplateTest

View File

@ -43,6 +43,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.RequestEntity;
import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestInitializer; 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.PATCH;
import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT; import static org.springframework.http.HttpMethod.PUT;
import static org.springframework.http.HttpMethod.QUERY;
import static org.springframework.http.MediaType.parseMediaType; import static org.springframework.http.MediaType.parseMediaType;
/** /**
@ -478,6 +480,46 @@ class RestTemplateTests {
verify(response).close(); 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<String> 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<String> 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 @Test
void put() throws Exception { void put() throws Exception {
mockTextPlainHttpMessageConverter(); mockTextPlainHttpMessageConverter();

View File

@ -140,7 +140,7 @@ class CorsConfigurationTests {
assertThat(config.getAllowedHeaders()).containsExactly("*"); assertThat(config.getAllowedHeaders()).containsExactly("*");
assertThat(combinedConfig).isNotNull(); assertThat(combinedConfig).isNotNull();
assertThat(combinedConfig.getAllowedMethods()) 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(); assertThat(combinedConfig.getExposedHeaders()).isEmpty();
combinedConfig = new CorsConfiguration().combine(config); combinedConfig = new CorsConfiguration().combine(config);
@ -148,7 +148,7 @@ class CorsConfigurationTests {
assertThat(config.getAllowedHeaders()).containsExactly("*"); assertThat(config.getAllowedHeaders()).containsExactly("*");
assertThat(combinedConfig).isNotNull(); assertThat(combinedConfig).isNotNull();
assertThat(combinedConfig.getAllowedMethods()) 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(); assertThat(combinedConfig.getExposedHeaders()).isEmpty();
} }
@ -394,7 +394,9 @@ class CorsConfigurationTests {
@Test @Test
void checkMethodAllowed() { void checkMethodAllowed() {
CorsConfiguration config = new CorsConfiguration(); 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"); config.addAllowedMethod("GET");
assertThat(config.checkHttpMethod(HttpMethod.GET)).containsExactly(HttpMethod.GET); assertThat(config.checkHttpMethod(HttpMethod.GET)).containsExactly(HttpMethod.GET);
@ -450,7 +452,7 @@ class CorsConfigurationTests {
assertThat(config.getAllowedOrigins()).containsExactly("*", "https://domain.com"); assertThat(config.getAllowedOrigins()).containsExactly("*", "https://domain.com");
assertThat(config.getAllowedHeaders()).containsExactly("*", "header1"); assertThat(config.getAllowedHeaders()).containsExactly("*", "header1");
assertThat(config.getAllowedMethods()).containsExactly("GET", "HEAD", "POST", "PATCH"); assertThat(config.getAllowedMethods()).containsExactly("GET", "QUERY", "HEAD", "POST", "PATCH");
} }
@Test @Test

View File

@ -252,7 +252,7 @@ class DefaultCorsProcessorTests {
this.processor.processRequest(this.conf, this.request, this.response); this.processor.processRequest(this.conf, this.request, this.response);
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); 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, assertThat(this.response.getHeaders(HttpHeaders.VARY)).contains(HttpHeaders.ORIGIN,
HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS); HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
} }

View File

@ -263,7 +263,7 @@ class DefaultCorsProcessorTests {
assertThat(response.getStatusCode()).isNull(); assertThat(response.getStatusCode()).isNull();
assertThat(response.getHeaders().get(VARY)).contains(ORIGIN, assertThat(response.getHeaders().get(VARY)).contains(ORIGIN,
ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS); 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 @Test

View File

@ -113,6 +113,10 @@ public class MvcAnnotationPredicates {
return new RequestMappingPredicate(path).method(RequestMethod.HEAD); 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<MethodParameter> { public static class ModelAttributePredicate implements Predicate<MethodParameter> {

View File

@ -177,6 +177,11 @@ final class DefaultWebClient implements WebClient {
return methodInternal(HttpMethod.OPTIONS); return methodInternal(HttpMethod.OPTIONS);
} }
@Override
public RequestHeadersUriSpec<?> query() {
return methodInternal(HttpMethod.QUERY);
}
@Override @Override
public RequestBodyUriSpec method(HttpMethod httpMethod) { public RequestBodyUriSpec method(HttpMethod httpMethod) {
return methodInternal(httpMethod); return methodInternal(httpMethod);

View File

@ -123,6 +123,12 @@ public interface WebClient {
*/ */
RequestHeadersUriSpec<?> options(); 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}. * Start building a request for the given {@code HttpMethod}.
* @return a spec for specifying the target URL * @return a spec for specifying the target URL

View File

@ -288,7 +288,7 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder {
*/ */
abstract static class AbstractServerResponse implements ServerResponse { abstract static class AbstractServerResponse implements ServerResponse {
private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD);
private final HttpStatusCode statusCode; private final HttpStatusCode statusCode;

View File

@ -266,6 +266,18 @@ public abstract class RequestPredicates {
return method(HttpMethod.OPTIONS).and(path(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. * 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 * @param extension the path extension to match against, ignoring case

View File

@ -43,7 +43,7 @@ import org.springframework.web.reactive.function.BodyInserters;
class ResourceHandlerFunction implements HandlerFunction<ServerResponse> { class ResourceHandlerFunction implements HandlerFunction<ServerResponse> {
private static final Set<HttpMethod> SUPPORTED_METHODS = private static final Set<HttpMethod> SUPPORTED_METHODS =
Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS);
private final Resource resource; private final Resource resource;
@ -66,6 +66,12 @@ class ResourceHandlerFunction implements HandlerFunction<ServerResponse> {
.build() .build()
.map(response -> response); .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)) { else if (HttpMethod.HEAD.equals(method)) {
Resource headResource = new HeadMethodResource(this.resource); Resource headResource = new HeadMethodResource(this.resource);
return EntityResponse.fromObject(headResource) return EntityResponse.fromObject(headResource)

View File

@ -231,6 +231,30 @@ class RouterFunctionBuilder implements RouterFunctions.Builder {
return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction); return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction);
} }
// QUERY
@Override
public RouterFunctions.Builder QUERY(HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.method(HttpMethod.QUERY), handlerFunction);
}
@Override
public RouterFunctions.Builder QUERY(RequestPredicate predicate, HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.method(HttpMethod.QUERY).and(predicate), handlerFunction);
}
@Override
public RouterFunctions.Builder QUERY(String pattern, HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.QUERY(pattern), handlerFunction);
}
@Override
public RouterFunctions.Builder QUERY(String pattern, RequestPredicate predicate,
HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.QUERY(pattern).and(predicate), handlerFunction);
}
// other // other
@Override @Override

View File

@ -696,6 +696,58 @@ public abstract class RouterFunctions {
*/ */
Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction<ServerResponse> handlerFunction); Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> handlerFunction);
/**
* Adds a route to the given handler function that handles all HTTP {@code QUERY} requests
* that match the given pattern and predicate.
* <p>For instance, the following example routes QUERY requests for "/user" that contain JSON
* to the {@code addUser} method in {@code userController}:
* <pre class="code">
* RouterFunction&lt;ServerResponse&gt; route =
* RouterFunctions.route()
* .QUERY("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::addUser)
* .build();
* </pre>
* @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<ServerResponse> handlerFunction);
/** /**
* Adds a route to the given handler function that handles all requests that match the * Adds a route to the given handler function that handles all requests that match the
* given predicate. * given predicate.

View File

@ -85,7 +85,7 @@ import org.springframework.web.util.pattern.PathPattern;
*/ */
public class ResourceWebHandler implements WebHandler, InitializingBean { public class ResourceWebHandler implements WebHandler, InitializingBean {
private static final Set<HttpMethod> SUPPORTED_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); private static final Set<HttpMethod> SUPPORTED_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD);
private static final Log logger = LogFactory.getLog(ResourceWebHandler.class); private static final Log logger = LogFactory.getLog(ResourceWebHandler.class);

View File

@ -157,6 +157,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi
if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.GET)) { if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.GET)) {
return requestMethodConditionCache.get(HttpMethod.GET); return requestMethodConditionCache.get(HttpMethod.GET);
} }
if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.QUERY)) {
return requestMethodConditionCache.get(HttpMethod.QUERY);
}
} }
return null; return null;
} }
@ -184,6 +187,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi
else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) { else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) {
return 1; return 1;
} }
else if (this.methods.contains(RequestMethod.QUERY) && other.methods.contains(RequestMethod.HEAD)) {
return 1;
}
} }
return 0; return 0;
} }

View File

@ -189,8 +189,9 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
HttpMethod httpMethod = request.getMethod(); HttpMethod httpMethod = request.getMethod();
Set<HttpMethod> methods = helper.getAllowedMethods(); Set<HttpMethod> methods = helper.getAllowedMethods();
if (HttpMethod.OPTIONS.equals(httpMethod)) { if (HttpMethod.OPTIONS.equals(httpMethod)) {
Set<MediaType> mediaTypes = helper.getConsumablePatchMediaTypes(); Set<MediaType> patchMediaTypes = helper.getConsumablePatchMediaTypes();
HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); Set<MediaType> queryMediaTypes = helper.getConsumableQueryMediaTypes();
HttpOptionsHandler handler = new HttpOptionsHandler(methods, patchMediaTypes, queryMediaTypes);
return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD);
} }
throw new MethodNotAllowedException(httpMethod, methods); throw new MethodNotAllowedException(httpMethod, methods);
@ -323,14 +324,23 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
* PATCH specified, or that have no methods at all. * PATCH specified, or that have no methods at all.
*/ */
public Set<MediaType> getConsumablePatchMediaTypes() { public Set<MediaType> getConsumablePatchMediaTypes() {
Set<MediaType> result = new LinkedHashSet<>(); return getConsumableMediaTypesForMethod(RequestMethod.PATCH);
for (PartialMatch match : this.partialMatches) { }
Set<RequestMethod> methods = match.getInfo().getMethodsCondition().getMethods();
if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) { /**
result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); * Return declared "consumable" types but only among those that have
} * PATCH specified, or that have no methods at all.
} */
return result; public Set<MediaType> getConsumableQueryMediaTypes() {
return getConsumableMediaTypesForMethod(RequestMethod.QUERY);
}
private Set<MediaType> 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(); private final HttpHeaders headers = new HttpHeaders();
public HttpOptionsHandler(Set<HttpMethod> declaredMethods, Set<MediaType> acceptPatch) { public HttpOptionsHandler(Set<HttpMethod> declaredMethods, Set<MediaType> acceptPatch, Set<MediaType> acceptQuery) {
this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); this.headers.setAllow(initAllowedHttpMethods(declaredMethods));
this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); this.headers.setAcceptPatch(new ArrayList<>(acceptPatch));
this.headers.setAcceptQuery(new ArrayList<>(acceptQuery));
} }
private static Set<HttpMethod> initAllowedHttpMethods(Set<HttpMethod> declaredMethods) { private static Set<HttpMethod> initAllowedHttpMethods(Set<HttpMethod> declaredMethods) {
@ -413,7 +424,7 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
} }
else { else {
Set<HttpMethod> result = new LinkedHashSet<>(declaredMethods); Set<HttpMethod> 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.HEAD);
} }
result.add(HttpMethod.OPTIONS); result.add(HttpMethod.OPTIONS);

View File

@ -56,7 +56,7 @@ import org.springframework.web.server.ServerWebExchange;
*/ */
public class ResponseEntityResultHandler extends AbstractMessageWriterResultHandler implements HandlerResultHandler { public class ResponseEntityResultHandler extends AbstractMessageWriterResultHandler implements HandlerResultHandler {
private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD);
/** /**

View File

@ -141,7 +141,7 @@ class ResourceHandlerFunctionTests {
Mono<ServerResponse> responseMono = this.handlerFunction.handle(request); Mono<ServerResponse> responseMono = this.handlerFunction.handle(request);
Mono<Void> result = responseMono.flatMap(response -> { Mono<Void> result = responseMono.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); 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); return response.writeTo(exchange, context);
}); });
@ -150,7 +150,7 @@ class ResourceHandlerFunctionTests {
.expectComplete() .expectComplete()
.verify(); .verify();
assertThat(mockResponse.getStatusCode()).isEqualTo(HttpStatus.OK); 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(); StepVerifier.create(mockResponse.getBody()).expectComplete().verify();
} }

View File

@ -377,7 +377,7 @@ class RequestMappingInfoHandlerMappingTests {
.isEqualTo(Collections.singletonList(new MediaType("application", "xml")))); .isEqualTo(Collections.singletonList(new MediaType("application", "xml"))));
} }
private void testHttpOptions(String requestURI, Set<HttpMethod> allowedMethods, @Nullable MediaType acceptPatch) { private void testHttpOptions(String requestURI, Set<HttpMethod> allowedMethods, @Nullable MediaType acceptMediaType) {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI)); ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI));
HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
@ -395,9 +395,15 @@ class RequestMappingInfoHandlerMappingTests {
HttpHeaders headers = (HttpHeaders) value; HttpHeaders headers = (HttpHeaders) value;
assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods);
if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { if (acceptMediaType != null) {
assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); 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) { private void testMediaTypeNotAcceptable(String url) {

View File

@ -117,7 +117,7 @@ class GlobalCorsConfigIntegrationTests extends AbstractRequestMappingIntegration
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(entity.getHeaders().getAccessControlAllowOrigin()).isEqualTo("*"); assertThat(entity.getHeaders().getAccessControlAllowOrigin()).isEqualTo("*");
assertThat(entity.getHeaders().getAccessControlAllowMethods()) assertThat(entity.getHeaders().getAccessControlAllowMethods())
.containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST); .containsExactly(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.POST);
} }
@ParameterizedHttpServerTest @ParameterizedHttpServerTest

View File

@ -43,7 +43,7 @@ import org.springframework.web.servlet.ModelAndView;
*/ */
abstract class AbstractServerResponse extends ErrorHandlingServerResponse { abstract class AbstractServerResponse extends ErrorHandlingServerResponse {
private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); private static final Set<HttpMethod> SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD);
private final HttpStatusCode statusCode; private final HttpStatusCode statusCode;

View File

@ -265,6 +265,18 @@ public abstract class RequestPredicates {
return method(HttpMethod.OPTIONS).and(path(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. * 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 * @param extension the path extension to match against, ignoring case

View File

@ -41,7 +41,7 @@ import org.springframework.http.HttpStatus;
class ResourceHandlerFunction implements HandlerFunction<ServerResponse> { class ResourceHandlerFunction implements HandlerFunction<ServerResponse> {
private static final Set<HttpMethod> SUPPORTED_METHODS = private static final Set<HttpMethod> SUPPORTED_METHODS =
Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); Set.of(HttpMethod.GET, HttpMethod.QUERY, HttpMethod.HEAD, HttpMethod.OPTIONS);
private final Resource resource; private final Resource resource;
@ -63,6 +63,11 @@ class ResourceHandlerFunction implements HandlerFunction<ServerResponse> {
.headers(headers -> this.headersConsumer.accept(this.resource, headers)) .headers(headers -> this.headersConsumer.accept(this.resource, headers))
.build(); .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)) { else if (HttpMethod.HEAD.equals(method)) {
Resource headResource = new HeadMethodResource(this.resource); Resource headResource = new HeadMethodResource(this.resource);
return EntityResponse.fromObject(headResource) return EntityResponse.fromObject(headResource)

View File

@ -229,6 +229,30 @@ class RouterFunctionBuilder implements RouterFunctions.Builder {
return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction); return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction);
} }
// QUERY
@Override
public RouterFunctions.Builder QUERY(HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.method(HttpMethod.QUERY), handlerFunction);
}
@Override
public RouterFunctions.Builder QUERY(RequestPredicate predicate, HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.method(HttpMethod.QUERY).and(predicate), handlerFunction);
}
@Override
public RouterFunctions.Builder QUERY(String pattern, HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.QUERY(pattern), handlerFunction);
}
@Override
public RouterFunctions.Builder QUERY(String pattern, RequestPredicate predicate,
HandlerFunction<ServerResponse> handlerFunction) {
return add(RequestPredicates.QUERY(pattern).and(predicate), handlerFunction);
}
// other // other
@Override @Override

View File

@ -610,6 +610,57 @@ public abstract class RouterFunctions {
*/ */
Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction<ServerResponse> handlerFunction); Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> handlerFunction);
/**
* Adds a route to the given handler function that handles all HTTP {@code QUERY} requests
* that match the given pattern and predicate.
* <p>For instance, the following example routes QUERY requests for "/user" that contain JSON
* to the {@code addUser} method in {@code userController}:
* <pre class="code">
* RouterFunction&lt;ServerResponse&gt; route =
* RouterFunctions.route()
* .QUERY("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::addUser)
* .build();
* </pre>
* @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<ServerResponse> handlerFunction);
/** /**
* Adds a route to the given handler function that handles all requests that match the * Adds a route to the given handler function that handles all requests that match the
* given predicate. * given predicate.

View File

@ -162,6 +162,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi
if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.GET)) { if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.GET)) {
return requestMethodConditionCache.get(HttpMethod.GET.name()); return requestMethodConditionCache.get(HttpMethod.GET.name());
} }
if (requestMethod.equals(RequestMethod.HEAD) && getMethods().contains(RequestMethod.QUERY)) {
return requestMethodConditionCache.get(HttpMethod.QUERY.name());
}
} }
return null; return null;
} }
@ -189,6 +192,9 @@ public final class RequestMethodsRequestCondition extends AbstractRequestConditi
else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) { else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) {
return 1; return 1;
} }
else if (this.methods.contains(RequestMethod.QUERY) && other.methods.contains(RequestMethod.HEAD)) {
return 1;
}
} }
return 0; return 0;
} }

View File

@ -519,7 +519,7 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
for (String method : declaredMethods) { for (String method : declaredMethods) {
HttpMethod httpMethod = HttpMethod.valueOf(method); HttpMethod httpMethod = HttpMethod.valueOf(method);
result.add(httpMethod); result.add(httpMethod);
if (httpMethod == HttpMethod.GET) { if (httpMethod == HttpMethod.GET || httpMethod == HttpMethod.QUERY) {
result.add(HttpMethod.HEAD); result.add(HttpMethod.HEAD);
} }
} }

View File

@ -243,7 +243,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
outputMessage.getServletResponse().setStatus(returnStatus.value()); outputMessage.getServletResponse().setStatus(returnStatus.value());
if (returnStatus.value() == HttpStatus.OK.value()) { if (returnStatus.value() == HttpStatus.OK.value()) {
HttpMethod method = inputMessage.getMethod(); 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)) { isResourceNotModified(inputMessage, outputMessage)) {
outputMessage.flush(); outputMessage.flush();
return; return;
@ -292,7 +292,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
HttpHeaders responseHeaders = response.getHeaders(); HttpHeaders responseHeaders = response.getHeaders();
String etag = responseHeaders.getETag(); String etag = responseHeaders.getETag();
long lastModifiedTimestamp = responseHeaders.getLastModified(); 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.ETAG);
responseHeaders.remove(HttpHeaders.LAST_MODIFIED); responseHeaders.remove(HttpHeaders.LAST_MODIFIED);
} }

View File

@ -141,7 +141,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
public ResourceHttpRequestHandler() { public ResourceHttpRequestHandler() {
super(HttpMethod.GET.name(), HttpMethod.HEAD.name()); super(HttpMethod.GET.name(), HttpMethod.QUERY.name(), HttpMethod.HEAD.name());
} }

View File

@ -931,7 +931,7 @@ public class MvcNamespaceTests {
CorsConfiguration config = configs.get("/**"); CorsConfiguration config = configs.get("/**");
assertThat(config).isNotNull(); assertThat(config).isNotNull();
assertThat(config.getAllowedOrigins().toArray()).isEqualTo(new String[]{"*"}); 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.getAllowedHeaders().toArray()).isEqualTo(new String[]{"*"});
assertThat(config.getExposedHeaders()).isNull(); assertThat(config.getExposedHeaders()).isNull();
assertThat(config.getAllowCredentials()).isNull(); assertThat(config.getAllowCredentials()).isNull();
@ -964,7 +964,7 @@ public class MvcNamespaceTests {
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123)); assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123));
config = configs.get("/resources/**"); config = configs.get("/resources/**");
assertThat(config.getAllowedOrigins().toArray()).isEqualTo(new String[]{"https://domain1.com"}); 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.getAllowedHeaders().toArray()).isEqualTo(new String[]{"*"});
assertThat(config.getExposedHeaders()).isNull(); assertThat(config.getExposedHeaders()).isNull();
assertThat(config.getAllowCredentials()).isNull(); assertThat(config.getAllowCredentials()).isNull();

View File

@ -175,7 +175,7 @@ class ResourceHandlerFunctionTests {
ServerResponse response = this.handlerFunction.handle(request); ServerResponse response = this.handlerFunction.handle(request);
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); 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(); MockHttpServletResponse servletResponse = new MockHttpServletResponse();
ModelAndView mav = response.writeTo(servletRequest, servletResponse, this.context); ModelAndView mav = response.writeTo(servletRequest, servletResponse, this.context);
@ -184,7 +184,7 @@ class ResourceHandlerFunctionTests {
assertThat(servletResponse.getStatus()).isEqualTo(200); assertThat(servletResponse.getStatus()).isEqualTo(200);
String allowHeader = servletResponse.getHeader("Allow"); String allowHeader = servletResponse.getHeader("Allow");
String[] methods = StringUtils.tokenizeToStringArray(allowHeader, ","); String[] methods = StringUtils.tokenizeToStringArray(allowHeader, ",");
assertThat(methods).containsExactlyInAnyOrder("GET","HEAD","OPTIONS"); assertThat(methods).containsExactlyInAnyOrder("GET","QUERY","HEAD","OPTIONS");
byte[] actualBytes = servletResponse.getContentAsByteArray(); byte[] actualBytes = servletResponse.getContentAsByteArray();
assertThat(actualBytes).isEmpty(); assertThat(actualBytes).isEmpty();
} }

View File

@ -185,7 +185,7 @@ public class HandlerMethodMappingTests {
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("https://domain.com"); 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 @Test

View File

@ -193,9 +193,10 @@ class RequestMappingInfoHandlerMappingTests {
void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception { void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception {
testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS", null); testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS", null);
testHttpOptions(mapping, "/person/1", "PUT,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, "/something", "PUT,POST", null);
testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar")); testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar"));
testHttpOptions(mapping, "/quid", "QUERY,HEAD,OPTIONS", null);
} }
@PathPatternsParameterizedTest @PathPatternsParameterizedTest
@ -572,6 +573,11 @@ class RequestMappingInfoHandlerMappingTests {
@RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar")
public void patchBaz(String value) { public void patchBaz(String value) {
} }
@RequestMapping(value = "/quid", method = RequestMethod.QUERY, consumes = "application/json", produces = "application/json")
public String query(@RequestBody String body) {
return "{}";
}
} }

View File

@ -149,6 +149,23 @@ class ResponseEntityExceptionHandlerTests {
assertThat(headers.getFirst(HttpHeaders.ACCEPT_PATCH)).isEqualTo("application/atom+xml, application/xml"); 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<Object> 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 @Test
void httpMediaTypeNotAcceptable() { void httpMediaTypeNotAcceptable() {
testException(new HttpMediaTypeNotAcceptableException("")); testException(new HttpMediaTypeNotAcceptableException(""));

View File

@ -114,7 +114,7 @@ class ResourceHttpRequestHandlerTests {
this.handler.handleRequest(this.request, this.response); this.handler.handleRequest(this.request, this.response);
assertThat(this.response.getStatus()).isEqualTo(200); 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 @Test

View File

@ -39,7 +39,7 @@ class WebContentGeneratorTests {
@Test @Test
void getAllowHeaderWithConstructorFalse() { void getAllowHeaderWithConstructorFalse() {
WebContentGenerator generator = new TestWebContentGenerator(false); 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 @Test
@ -59,7 +59,7 @@ class WebContentGeneratorTests {
void getAllowHeaderWithSupportedMethodsSetterEmpty() { void getAllowHeaderWithSupportedMethodsSetterEmpty() {
WebContentGenerator generator = new TestWebContentGenerator(); WebContentGenerator generator = new TestWebContentGenerator();
generator.setSupportedMethods(); 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 @Test