feat: add support for the HTTP QUERY method (RFC 10008)

QUERY (RFC 10008, June 2026; registered in the IANA HTTP Method Registry) is
safe and idempotent like GET but carries a request body like POST. It is wired
through exactly like PATCH:

- actix-web: `guard::Query()`, `web::query()`, `Resource::query()`, and
  `TestRequest::query()`
- actix-web-codegen: `#[query]` routing macro (plus re-export from actix-web)

`http` 0.2 has no `Method::QUERY` constant, so the method is built with
`Method::from_bytes(b"QUERY").unwrap()`, with a TODO to switch to the constant
once the `http` dependency is bumped (#3384). No dependency changes.
`actix-http` needs no change (it uses `http::Method` directly).

Tests mirror the existing PATCH coverage and add a body-carrying QUERY test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Oliveira 2026-07-07 19:05:40 -03:00
parent c6249cc00c
commit 38ef6b73f5
11 changed files with 108 additions and 4 deletions

View File

@ -2,6 +2,7 @@
## Unreleased
- Add `#[query]` macro for routing the HTTP `QUERY` method (RFC 10008).
- Minimum supported Rust version (MSRV) is now 1.88.
## 4.3.0

View File

@ -21,7 +21,8 @@
//! There is a macro to set up a handler for each of the most common HTTP methods that also define
//! additional guards and route-specific middleware.
//!
//! See docs for: [GET], [POST], [PATCH], [PUT], [DELETE], [HEAD], [CONNECT], [OPTIONS], [TRACE]
//! See docs for: [GET], [POST], [PATCH], [PUT], [DELETE], [HEAD], [CONNECT], [OPTIONS], [TRACE],
//! [QUERY]
//!
//! ```
//! # use actix_web::HttpResponse;
@ -71,6 +72,7 @@
//! [TRACE]: macro@trace
//! [PATCH]: macro@patch
//! [DELETE]: macro@delete
//! [QUERY]: macro@query
#![recursion_limit = "512"]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
@ -195,6 +197,7 @@ method_macro!(Connect, connect);
method_macro!(Options, options);
method_macro!(Trace, trace);
method_macro!(Patch, patch);
method_macro!(Query, query);
/// Prepends a path prefix to all handlers using routing macros inside the attached module.
///

View File

@ -101,6 +101,7 @@ standard_method_type! {
Options, OPTIONS, options,
Trace, TRACE, trace,
Patch, PATCH, patch,
Query, QUERY, query,
}
impl TryFrom<&syn::LitStr> for MethodType {

View File

@ -11,7 +11,7 @@ use actix_web::{
web, App, Error, HttpRequest, HttpResponse, Responder,
};
use actix_web_codegen::{
connect, delete, get, head, options, patch, post, put, route, routes, trace,
connect, delete, get, head, options, patch, post, put, query, route, routes, trace,
};
use futures_core::future::LocalBoxFuture;
@ -36,6 +36,11 @@ async fn patch_test() -> impl Responder {
HttpResponse::Ok()
}
#[query("/test")]
async fn query_test() -> impl Responder {
HttpResponse::Ok()
}
#[post("/test")]
async fn post_test() -> impl Responder {
HttpResponse::NoContent()
@ -258,6 +263,7 @@ async fn test_body() {
.service(options_test)
.service(trace_test)
.service(patch_test)
.service(query_test)
.service(test_handler)
.service(route_test)
.service(routes_overlapping_test)
@ -290,6 +296,13 @@ async fn test_body() {
let response = request.send().await.unwrap();
assert!(response.status().is_success());
let request = srv.request(
http::Method::from_bytes(b"QUERY").unwrap(),
srv.url("/test"),
);
let response = request.send().await.unwrap();
assert!(response.status().is_success());
let request = srv.request(http::Method::PUT, srv.url("/test"));
let response = request.send().await.unwrap();
assert!(response.status().is_success());

View File

@ -2,6 +2,8 @@
## Unreleased
- Add support for the HTTP `QUERY` method (RFC 10008): `web::query()`, `guard::Query()`, `Resource::query()`, and `TestRequest::query()`.
## 4.14.0
- Add `HttpRequest::{cookies_raw,cookie_raw}` and `ServiceRequest::{cookies_raw,cookie_raw}` for reading request cookies without percent-decoding names and values. [#3542]

View File

@ -425,6 +425,9 @@ impl Guard for MethodGuard {
macro_rules! method_guard {
($method_fn:ident, $method_const:ident) => {
method_guard!($method_fn, $method_const, HttpMethod::$method_const);
};
($method_fn:ident, $method_const:ident, $method:expr) => {
#[doc = concat!("Creates a guard that matches the `", stringify!($method_const), "` request method.")]
///
/// # Examples
@ -438,7 +441,7 @@ macro_rules! method_guard {
/// ```
#[allow(non_snake_case)]
pub fn $method_fn() -> impl Guard {
MethodGuard(HttpMethod::$method_const)
MethodGuard($method)
}
};
}
@ -452,6 +455,9 @@ method_guard!(Options, OPTIONS);
method_guard!(Connect, CONNECT);
method_guard!(Patch, PATCH);
method_guard!(Trace, TRACE);
// `QUERY` (RFC 10008) is a safe, idempotent method that carries a request body.
// TODO: replace with `HttpMethod::QUERY` once the `http` dependency is bumped (see #3384).
method_guard!(Query, QUERY, HttpMethod::from_bytes(b"QUERY").unwrap());
/// Creates a guard that matches if request contains given header name and value.
///
@ -543,6 +549,10 @@ mod tests {
assert!(Patch().check(&req.guard_ctx()));
assert!(!Patch().check(&get_req.guard_ctx()));
let req = TestRequest::query().to_srv_request();
assert!(Query().check(&req.guard_ctx()));
assert!(!Query().check(&get_req.guard_ctx()));
let r = TestRequest::delete().to_srv_request();
assert!(Delete().check(&r.guard_ctx()));
assert!(!Delete().check(&get_req.guard_ctx()));

View File

@ -155,6 +155,7 @@ codegen_reexport!(delete);
codegen_reexport!(trace);
codegen_reexport!(connect);
codegen_reexport!(options);
codegen_reexport!(query);
codegen_reexport!(scope);
pub(crate) type BoxError = Box<dyn std::error::Error>;

View File

@ -403,6 +403,7 @@ where
route_shortcut!(delete, "DELETE");
route_shortcut!(head, "HEAD");
route_shortcut!(trace, "TRACE");
route_shortcut!(query, "QUERY");
}
impl<T, B> HttpServiceFactory for Resource<T>
@ -810,6 +811,32 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[actix_rt::test]
async fn test_query_method() {
let srv = init_service(
App::new().service(web::resource("/test").route(web::query().to(HttpResponse::Ok))),
)
.await;
// a QUERY request is routed to the `web::query()` handler
let req = TestRequest::with_uri("/test")
.method(Method::from_bytes(b"QUERY").unwrap())
.to_request();
let resp = call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::OK);
// any other method is rejected with `405 Method Not Allowed`
let req = TestRequest::with_uri("/test")
.method(Method::GET)
.to_request();
let resp = call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
resp.headers().get(header::ALLOW).unwrap().as_bytes(),
b"QUERY"
);
}
// allow deprecated `{App, Resource}::data`
#[allow(deprecated)]
#[actix_rt::test]

View File

@ -111,6 +111,14 @@ impl TestRequest {
TestRequest::default().method(Method::PATCH)
}
/// Constructs test request with QUERY method.
///
/// `QUERY` (RFC 10008) is safe and idempotent like `GET`, but carries a request body.
// TODO: use `Method::QUERY` once the `http` dependency is bumped (see #3384).
pub fn query() -> TestRequest {
TestRequest::default().method(Method::from_bytes(b"QUERY").unwrap())
}
/// Constructs test request with DELETE method.
pub fn delete() -> TestRequest {
TestRequest::default().method(Method::DELETE)

View File

@ -371,6 +371,7 @@ mod tests {
web::resource("/index.html")
.route(web::put().to(|| HttpResponse::Ok().body("put!")))
.route(web::patch().to(|| HttpResponse::Ok().body("patch!")))
.route(web::query().to(|| HttpResponse::Ok().body("query!")))
.route(web::delete().to(|| HttpResponse::Ok().body("delete!"))),
),
)
@ -392,11 +393,42 @@ mod tests {
let result = call_and_read_body(&app, patch_req).await;
assert_eq!(result, Bytes::from_static(b"patch!"));
let query_req = TestRequest::query()
.uri("/index.html")
.insert_header((header::CONTENT_TYPE, "application/json"))
.to_request();
let result = call_and_read_body(&app, query_req).await;
assert_eq!(result, Bytes::from_static(b"query!"));
let delete_req = TestRequest::delete().uri("/index.html").to_request();
let result = call_and_read_body(&app, delete_req).await;
assert_eq!(result, Bytes::from_static(b"delete!"));
}
// `QUERY` is safe and idempotent like `GET`, but (like `POST`) carries a request body;
// this proves body extractors work for `QUERY` handlers. See RFC 10008.
#[actix_rt::test]
async fn test_query_carries_json_body() {
let app =
init_service(App::new().service(web::resource("/people").route(
web::query().to(|person: web::Json<Person>| HttpResponse::Ok().json(person)),
)))
.await;
let payload = r#"{"id":"12345","name":"User name"}"#.as_bytes();
let req = TestRequest::query()
.uri("/people")
.insert_header((header::CONTENT_TYPE, "application/json"))
.set_payload(payload)
.to_request();
let result: Person = call_and_read_body_json(&app, req).await;
assert_eq!(result.id, "12345");
assert_eq!(result.name, "User name");
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Person {
id: String,

View File

@ -101,6 +101,9 @@ pub fn route() -> Route {
macro_rules! method_route {
($method_fn:ident, $method_const:ident) => {
method_route!($method_fn, $method_const, Method::$method_const);
};
($method_fn:ident, $method_const:ident, $method:expr) => {
#[doc = concat!(" Creates a new route with `", stringify!($method_const), "` method guard.")]
///
/// # Examples
@ -115,7 +118,7 @@ macro_rules! method_route {
/// );
/// ```
pub fn $method_fn() -> Route {
method(Method::$method_const)
method($method)
}
};
}
@ -127,6 +130,9 @@ method_route!(patch, PATCH);
method_route!(delete, DELETE);
method_route!(head, HEAD);
method_route!(trace, TRACE);
// `QUERY` (RFC 10008) is a safe, idempotent method that carries a request body.
// TODO: replace with `Method::QUERY` once the `http` dependency is bumped (see #3384).
method_route!(query, QUERY, Method::from_bytes(b"QUERY").unwrap());
/// Creates a new route with specified method guard.
///