Add QUERY HTTP method

Signed-off-by: Mario Daniel Ruiz Saavedra <desiderantes93@gmail.com>
This commit is contained in:
Mario Daniel Ruiz Saavedra
2026-08-20 14:13:55 +02:00
committed by Brian Clozel
parent 555ac3768d
commit 4a64537ac6
26 changed files with 298 additions and 52 deletions
@@ -129,6 +129,13 @@ 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>
*/
public static final String ACCEPT_RANGES = "Accept-Ranges";
/**
* The HTTP {@code Accept-Query} header field name.
* @since 7.1
* @see <a href="https://www.rfc-editor.org/rfc/rfc10008.html#section-3">Section 3 of RFC 10008</a>
*/
public static final String ACCEPT_QUERY = "Accept-Query";
/**
* The CORS {@code Access-Control-Allow-Credentials} response header field name.
* @see <a href="https://www.w3.org/TR/cors/">CORS W3C recommendation</a>
@@ -648,6 +655,27 @@ public class HttpHeaders implements Serializable {
return MediaType.parseMediaTypes(get(ACCEPT_PATCH));
}
/**
* Set the list of acceptable {@linkplain MediaType media types} for
* {@code QUERY} methods, as specified by the {@code Accept-Query} header.
* @since 7.1
*/
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 7.1
*/
public List<MediaType> getAcceptQuery() {
return MediaType.parseMediaTypes(get(ACCEPT_QUERY));
}
/**
* Set the (new) value of the {@code Access-Control-Allow-Credentials} response header.
*/
@@ -39,25 +39,25 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
/**
* 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");
/**
* 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");
/**
* 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");
/**
* 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");
@@ -69,23 +69,30 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
/**
* 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");
/**
* 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");
/**
* 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");
private static final HttpMethod[] values = new HttpMethod[] { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE };
/**
* The HTTP method {@code QUERY}.
* @since 7.1
* @see <a href="https://www.rfc-editor.org/rfc/rfc10008.html#section-2">The HTTP QUERY Method, section 2</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;
@@ -99,7 +106,7 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
* Returns an array containing the standard HTTP methods. Specifically,
* this method returns an array containing {@link #GET}, {@link #HEAD},
* {@link #POST}, {@link #PUT}, {@link #PATCH}, {@link #DELETE},
* {@link #OPTIONS}, and {@link #TRACE}.
* {@link #OPTIONS}, {@link #TRACE}, and {@link #QUERY}.
*
* <p>Note that the returned value does not include any HTTP methods defined
* in WebDav.
@@ -137,6 +144,7 @@ public final class HttpMethod implements Comparable<HttpMethod>, Serializable {
case "DELETE" -> DELETE;
case "OPTIONS" -> OPTIONS;
case "TRACE" -> TRACE;
case "QUERY" -> QUERY;
default -> new HttpMethod(method);
};
}
@@ -32,6 +32,7 @@ import org.apache.hc.client5.http.classic.methods.HttpPatch;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.classic.methods.HttpPut;
import org.apache.hc.client5.http.classic.methods.HttpTrace;
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
import org.apache.hc.client5.http.config.Configurable;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClients;
@@ -300,6 +301,9 @@ public class HttpComponentsClientHttpRequestFactory implements ClientHttpRequest
else if (HttpMethod.TRACE.equals(httpMethod)) {
return new HttpTrace(uri);
}
else if (HttpMethod.QUERY.equals(httpMethod)) {
return new HttpUriRequestBase(HttpMethod.QUERY.name(), uri);
}
throw new IllegalArgumentException("Invalid HTTP method: " + httpMethod);
}
@@ -132,6 +132,9 @@ public class HttpMediaTypeNotSupportedException extends HttpMediaTypeException {
if (HttpMethod.PATCH.equals(this.httpMethod)) {
headers.setAcceptPatch(getSupportedMediaTypes());
}
if (HttpMethod.QUERY.equals(this.httpMethod)) {
headers.setAcceptQuery(getSupportedMediaTypes());
}
return headers;
}
@@ -26,7 +26,7 @@ import org.springframework.util.Assert;
* {@link RequestMapping#method()} attribute of the {@link RequestMapping} annotation.
*
* <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
* told to dispatch those request types as well: Check out the "dispatchOptionsRequest"
* and "dispatchTraceRequest" properties, switching them to "true" if necessary.
@@ -39,7 +39,7 @@ import org.springframework.util.Assert;
*/
public enum RequestMethod {
GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE;
GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, QUERY;
/**
@@ -60,6 +60,7 @@ public enum RequestMethod {
case "DELETE" -> DELETE;
case "OPTIONS" -> OPTIONS;
case "TRACE" -> TRACE;
case "QUERY" -> QUERY;
default -> null;
};
}
@@ -92,6 +93,7 @@ public enum RequestMethod {
case DELETE -> HttpMethod.DELETE;
case OPTIONS -> HttpMethod.OPTIONS;
case TRACE -> HttpMethod.TRACE;
case QUERY -> HttpMethod.QUERY;
};
}
@@ -47,7 +47,7 @@ import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
/**
* {@code Filter} that parses form data for HTTP PUT, PATCH, and DELETE requests
* {@code Filter} that parses form data for HTTP PUT, PATCH, DELETE, and QUERY requests
* and exposes it as Servlet request parameters. By default, the Servlet spec
* only requires this for HTTP POST.
*
@@ -56,7 +56,7 @@ import org.springframework.util.StringUtils;
*/
public class FormContentFilter extends OncePerRequestFilter {
private static final List<String> HTTP_METHODS = Arrays.asList("PUT", "PATCH", "DELETE");
private static final List<String> HTTP_METHODS = Arrays.asList("PUT", "PATCH", "DELETE", "QUERY");
private FormHttpMessageConverter formConverter = new FormHttpMessageConverter();
@@ -38,7 +38,7 @@ import org.springframework.web.util.WebUtils;
* is to use a normal POST with an additional hidden form field ({@code _method})
* to pass the "real" HTTP method along. This filter reads that parameter and changes
* the {@link HttpServletRequestWrapper#getMethod()} return value accordingly.
* Only {@code "PUT"}, {@code "DELETE"} and {@code "PATCH"} HTTP methods are allowed.
* Only {@code "PUT"}, {@code "DELETE"}, {@code "PATCH"}, and {@code "QUERY"} HTTP methods are allowed.
*
* <p>The name of the request parameter defaults to {@code _method}, but can be
* adapted via the {@link #setMethodParam(String) methodParam} property.
@@ -55,7 +55,7 @@ import org.springframework.web.util.WebUtils;
public class HiddenHttpMethodFilter extends OncePerRequestFilter {
private static final List<String> ALLOWED_METHODS =
List.of(HttpMethod.PUT.name(), HttpMethod.DELETE.name(), HttpMethod.PATCH.name());
List.of(HttpMethod.PUT.name(), HttpMethod.DELETE.name(), HttpMethod.PATCH.name(), HttpMethod.QUERY.name());
/** Default method parameter: {@code _method}. */
public static final String DEFAULT_METHOD_PARAM = "_method";
@@ -47,7 +47,7 @@ import org.springframework.web.server.WebFilterChain;
public class HiddenHttpMethodFilter implements WebFilter {
private static final List<HttpMethod> ALLOWED_METHODS =
List.of(HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH);
List.of(HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH, HttpMethod.QUERY);
/** Default name of the form parameter with the HTTP method to use. */
public static final String DEFAULT_METHOD_PARAMETER_NAME = "_method";
@@ -161,6 +161,9 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException
if (this.method == HttpMethod.PATCH) {
headers.setAcceptPatch(this.supportedMediaTypes);
}
if (this.method == HttpMethod.QUERY) {
headers.setAcceptQuery(this.supportedMediaTypes);
}
return headers;
}
@@ -44,12 +44,12 @@ class HttpMethodTests {
void values() {
HttpMethod[] values = HttpMethod.values();
assertThat(values).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT,
HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE);
HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE, HttpMethod.QUERY);
// check defensive copy
values[0] = HttpMethod.POST;
assertThat(HttpMethod.values()).containsExactly(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT,
HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE);
HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE, HttpMethod.QUERY);
}
@Test
@@ -29,7 +29,7 @@ class RequestMethodTests {
@Test
void resolveString() {
String[] methods = new String[]{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"};
String[] methods = new String[]{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "QUERY"};
for (String httpMethod : methods) {
RequestMethod requestMethod = RequestMethod.resolve(httpMethod);
assertThat(requestMethod).isNotNull();
@@ -75,7 +75,7 @@ abstract class AbstractMockWebServerTests {
private MockResponse getRequest(RecordedRequest request, byte[] body, @Nullable String contentType) {
if (request.getMethod().equals("OPTIONS")) {
return new MockResponse.Builder().code(200).setHeader("Allow", "GET, OPTIONS, HEAD, TRACE").build();
return new MockResponse.Builder().code(200).setHeader("Allow", "GET, QUERY, OPTIONS, HEAD, TRACE").build();
}
Buffer buf = new Buffer();
buf.write(body);
@@ -240,6 +240,29 @@ abstract class AbstractMockWebServerTests {
return new MockResponse.Builder().code(202).build();
}
private MockResponse queryRequest(RecordedRequest request, String expectedRequestContent,
String contentType, byte[] responseBody) {
assertThat(request.getHeaders().values(CONTENT_LENGTH)).hasSize(1);
assertThat(Integer.parseInt(request.getHeaders().get(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0);
String requestContentType = request.getHeaders().get(CONTENT_TYPE);
assertThat(requestContentType).as("No content-type").isNotNull();
Charset charset = StandardCharsets.ISO_8859_1;
if (requestContentType.contains("charset=")) {
String charsetName = requestContentType.split("charset=")[1];
charset = Charset.forName(charsetName);
}
assertThat(request.getBody().string(charset)).as("Invalid request body").isEqualTo(expectedRequestContent);
Buffer buf = new Buffer();
buf.write(responseBody);
return new MockResponse.Builder()
.code(200)
.setHeader(CONTENT_TYPE, contentType)
.setHeader(CONTENT_LENGTH, responseBody.length)
.body(buf)
.build();
}
protected class TestDispatcher extends Dispatcher {
@@ -302,6 +325,9 @@ abstract class AbstractMockWebServerTests {
else if (request.getTarget().equals("/put")) {
return putRequest(request, helloWorld);
}
else if (request.getTarget().equals("/query")) {
return queryRequest(request, helloWorld, textContentType.toString(), helloWorldBytes);
}
return new MockResponse.Builder().code(404).build();
}
catch (Throwable ex) {
@@ -295,7 +295,7 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests {
setUpClient(clientHttpRequestFactory);
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
@@ -58,14 +58,14 @@ class FormContentFilterTests {
@Test
void wrapPutPatchAndDeleteOnly() throws Exception {
void wrapPutPatchQueryAndDeleteOnly() throws Exception {
for (HttpMethod method : HttpMethod.values()) {
MockHttpServletRequest request = new MockHttpServletRequest(method.name(), "/");
request.setContent("foo=bar".getBytes(StandardCharsets.ISO_8859_1));
request.setContentType("application/x-www-form-urlencoded; charset=ISO-8859-1");
this.filterChain = new MockFilterChain();
this.filter.doFilter(request, this.response, this.filterChain);
if (method == HttpMethod.PUT || method == HttpMethod.PATCH || method == HttpMethod.DELETE) {
if (method == HttpMethod.PUT || method == HttpMethod.PATCH || method == HttpMethod.DELETE || method == HttpMethod.QUERY) {
assertThat(this.filterChain.getRequest()).isNotSameAs(request);
}
else {
@@ -113,6 +113,10 @@ public class MvcAnnotationPredicates {
return new RequestMappingPredicate(path).method(RequestMethod.HEAD);
}
public static RequestMappingPredicate queryMapping(String... path) {
return new RequestMappingPredicate(path).method(RequestMethod.QUERY);
}
public static class ModelAttributePredicate implements Predicate<MethodParameter> {