mirror of https://github.com/fafhrd91/actix-web
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:
parent
c6249cc00c
commit
38ef6b73f5
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Add `#[query]` macro for routing the HTTP `QUERY` method (RFC 10008).
|
||||||
- Minimum supported Rust version (MSRV) is now 1.88.
|
- Minimum supported Rust version (MSRV) is now 1.88.
|
||||||
|
|
||||||
## 4.3.0
|
## 4.3.0
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
//! There is a macro to set up a handler for each of the most common HTTP methods that also define
|
//! 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.
|
//! 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;
|
//! # use actix_web::HttpResponse;
|
||||||
|
|
@ -71,6 +72,7 @@
|
||||||
//! [TRACE]: macro@trace
|
//! [TRACE]: macro@trace
|
||||||
//! [PATCH]: macro@patch
|
//! [PATCH]: macro@patch
|
||||||
//! [DELETE]: macro@delete
|
//! [DELETE]: macro@delete
|
||||||
|
//! [QUERY]: macro@query
|
||||||
|
|
||||||
#![recursion_limit = "512"]
|
#![recursion_limit = "512"]
|
||||||
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
|
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
|
||||||
|
|
@ -195,6 +197,7 @@ method_macro!(Connect, connect);
|
||||||
method_macro!(Options, options);
|
method_macro!(Options, options);
|
||||||
method_macro!(Trace, trace);
|
method_macro!(Trace, trace);
|
||||||
method_macro!(Patch, patch);
|
method_macro!(Patch, patch);
|
||||||
|
method_macro!(Query, query);
|
||||||
|
|
||||||
/// Prepends a path prefix to all handlers using routing macros inside the attached module.
|
/// Prepends a path prefix to all handlers using routing macros inside the attached module.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ standard_method_type! {
|
||||||
Options, OPTIONS, options,
|
Options, OPTIONS, options,
|
||||||
Trace, TRACE, trace,
|
Trace, TRACE, trace,
|
||||||
Patch, PATCH, patch,
|
Patch, PATCH, patch,
|
||||||
|
Query, QUERY, query,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&syn::LitStr> for MethodType {
|
impl TryFrom<&syn::LitStr> for MethodType {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use actix_web::{
|
||||||
web, App, Error, HttpRequest, HttpResponse, Responder,
|
web, App, Error, HttpRequest, HttpResponse, Responder,
|
||||||
};
|
};
|
||||||
use actix_web_codegen::{
|
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;
|
use futures_core::future::LocalBoxFuture;
|
||||||
|
|
||||||
|
|
@ -36,6 +36,11 @@ async fn patch_test() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[query("/test")]
|
||||||
|
async fn query_test() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/test")]
|
#[post("/test")]
|
||||||
async fn post_test() -> impl Responder {
|
async fn post_test() -> impl Responder {
|
||||||
HttpResponse::NoContent()
|
HttpResponse::NoContent()
|
||||||
|
|
@ -258,6 +263,7 @@ async fn test_body() {
|
||||||
.service(options_test)
|
.service(options_test)
|
||||||
.service(trace_test)
|
.service(trace_test)
|
||||||
.service(patch_test)
|
.service(patch_test)
|
||||||
|
.service(query_test)
|
||||||
.service(test_handler)
|
.service(test_handler)
|
||||||
.service(route_test)
|
.service(route_test)
|
||||||
.service(routes_overlapping_test)
|
.service(routes_overlapping_test)
|
||||||
|
|
@ -290,6 +296,13 @@ async fn test_body() {
|
||||||
let response = request.send().await.unwrap();
|
let response = request.send().await.unwrap();
|
||||||
assert!(response.status().is_success());
|
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 request = srv.request(http::Method::PUT, srv.url("/test"));
|
||||||
let response = request.send().await.unwrap();
|
let response = request.send().await.unwrap();
|
||||||
assert!(response.status().is_success());
|
assert!(response.status().is_success());
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Add support for the HTTP `QUERY` method (RFC 10008): `web::query()`, `guard::Query()`, `Resource::query()`, and `TestRequest::query()`.
|
||||||
|
|
||||||
## 4.14.0
|
## 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]
|
- Add `HttpRequest::{cookies_raw,cookie_raw}` and `ServiceRequest::{cookies_raw,cookie_raw}` for reading request cookies without percent-decoding names and values. [#3542]
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,9 @@ impl Guard for MethodGuard {
|
||||||
|
|
||||||
macro_rules! method_guard {
|
macro_rules! method_guard {
|
||||||
($method_fn:ident, $method_const:ident) => {
|
($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.")]
|
#[doc = concat!("Creates a guard that matches the `", stringify!($method_const), "` request method.")]
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
|
|
@ -438,7 +441,7 @@ macro_rules! method_guard {
|
||||||
/// ```
|
/// ```
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
pub fn $method_fn() -> impl Guard {
|
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!(Connect, CONNECT);
|
||||||
method_guard!(Patch, PATCH);
|
method_guard!(Patch, PATCH);
|
||||||
method_guard!(Trace, TRACE);
|
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.
|
/// 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(&req.guard_ctx()));
|
||||||
assert!(!Patch().check(&get_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();
|
let r = TestRequest::delete().to_srv_request();
|
||||||
assert!(Delete().check(&r.guard_ctx()));
|
assert!(Delete().check(&r.guard_ctx()));
|
||||||
assert!(!Delete().check(&get_req.guard_ctx()));
|
assert!(!Delete().check(&get_req.guard_ctx()));
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ codegen_reexport!(delete);
|
||||||
codegen_reexport!(trace);
|
codegen_reexport!(trace);
|
||||||
codegen_reexport!(connect);
|
codegen_reexport!(connect);
|
||||||
codegen_reexport!(options);
|
codegen_reexport!(options);
|
||||||
|
codegen_reexport!(query);
|
||||||
codegen_reexport!(scope);
|
codegen_reexport!(scope);
|
||||||
|
|
||||||
pub(crate) type BoxError = Box<dyn std::error::Error>;
|
pub(crate) type BoxError = Box<dyn std::error::Error>;
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,7 @@ where
|
||||||
route_shortcut!(delete, "DELETE");
|
route_shortcut!(delete, "DELETE");
|
||||||
route_shortcut!(head, "HEAD");
|
route_shortcut!(head, "HEAD");
|
||||||
route_shortcut!(trace, "TRACE");
|
route_shortcut!(trace, "TRACE");
|
||||||
|
route_shortcut!(query, "QUERY");
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T, B> HttpServiceFactory for Resource<T>
|
impl<T, B> HttpServiceFactory for Resource<T>
|
||||||
|
|
@ -810,6 +811,32 @@ mod tests {
|
||||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
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 `{App, Resource}::data`
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
#[actix_rt::test]
|
#[actix_rt::test]
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,14 @@ impl TestRequest {
|
||||||
TestRequest::default().method(Method::PATCH)
|
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.
|
/// Constructs test request with DELETE method.
|
||||||
pub fn delete() -> TestRequest {
|
pub fn delete() -> TestRequest {
|
||||||
TestRequest::default().method(Method::DELETE)
|
TestRequest::default().method(Method::DELETE)
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,7 @@ mod tests {
|
||||||
web::resource("/index.html")
|
web::resource("/index.html")
|
||||||
.route(web::put().to(|| HttpResponse::Ok().body("put!")))
|
.route(web::put().to(|| HttpResponse::Ok().body("put!")))
|
||||||
.route(web::patch().to(|| HttpResponse::Ok().body("patch!")))
|
.route(web::patch().to(|| HttpResponse::Ok().body("patch!")))
|
||||||
|
.route(web::query().to(|| HttpResponse::Ok().body("query!")))
|
||||||
.route(web::delete().to(|| HttpResponse::Ok().body("delete!"))),
|
.route(web::delete().to(|| HttpResponse::Ok().body("delete!"))),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -392,11 +393,42 @@ mod tests {
|
||||||
let result = call_and_read_body(&app, patch_req).await;
|
let result = call_and_read_body(&app, patch_req).await;
|
||||||
assert_eq!(result, Bytes::from_static(b"patch!"));
|
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 delete_req = TestRequest::delete().uri("/index.html").to_request();
|
||||||
let result = call_and_read_body(&app, delete_req).await;
|
let result = call_and_read_body(&app, delete_req).await;
|
||||||
assert_eq!(result, Bytes::from_static(b"delete!"));
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@ pub fn route() -> Route {
|
||||||
|
|
||||||
macro_rules! method_route {
|
macro_rules! method_route {
|
||||||
($method_fn:ident, $method_const:ident) => {
|
($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.")]
|
#[doc = concat!(" Creates a new route with `", stringify!($method_const), "` method guard.")]
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
|
|
@ -115,7 +118,7 @@ macro_rules! method_route {
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
pub fn $method_fn() -> 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!(delete, DELETE);
|
||||||
method_route!(head, HEAD);
|
method_route!(head, HEAD);
|
||||||
method_route!(trace, TRACE);
|
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.
|
/// Creates a new route with specified method guard.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue