1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
//! HTTP `QUERY` method routing — RFC 10008 (issue #1108, epic #1107).
//!
//! `QUERY` is the "safe GET with a body": safe + idempotent like GET, but
//! the query payload travels in the request body, so complex search /
//! filter criteria don't have to squeeze into a querystring. axum 0.8
//! can't route it natively — [`axum::routing::MethodFilter`] is a closed
//! set (tokio-rs/axum#3799) — so this module wraps the existing
//! `MethodRouter` in an `any(…)` shim that peels `QUERY` off for its own
//! handler and delegates every other method back to the wrapped router.
//!
//! ```ignore
//! use rustango::http_query::{query, QueryRouterExt};
//! use axum::routing::get;
//!
//! let app = axum::Router::new()
//! // QUERY-only route.
//! .route("/search", query(search))
//! // GET + QUERY on one path — same handler shape as axum's own
//! // chaining. Chain `.query()` LAST (see below).
//! .route("/products", get(list_products).query(search_products));
//! ```
//!
//! ## How it works
//!
//! [`QueryRouterExt::query`] wraps the *entire* existing [`MethodRouter`]
//! inside a fresh `any(…)` router. The shim receives every request:
//! `QUERY` goes to the query handler; anything else is delegated to the
//! wrapped router, which dispatches its registered methods (and its own
//! fallback / default 405) exactly as before. A 405 coming back from the
//! wrapped router gets `QUERY` appended to its `Allow` header, so
//! `POST /products` correctly answers `Allow: GET,HEAD,QUERY` (axum emits
//! the method list comma-separated with no spaces).
//!
//! ## Chain `.query()` last
//!
//! Methods chained *after* `.query()` register on the outer router, which
//! skips axum's `Allow` bookkeeping — `query(h).get(a)` routes correctly
//! but 405s report `Allow: QUERY` without `GET`. `get(a).query(h)` is
//! always right. When axum lands native QUERY support (axum#3799) the
//! internals here can switch to it without changing this call surface.
//!
//! ## Body semantics
//!
//! This module routes; it does not read bodies. Pair with the unified
//! params extractor (#1109) to serve `GET /x?a=1` and `QUERY /x` with
//! body `a=1` from one handler.
use std::future::Future;
use std::pin::Pin;
use std::sync::LazyLock;
use axum::extract::Request;
use axum::handler::Handler;
use axum::http::header::ALLOW;
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
use axum::response::Response;
use axum::routing::{any, MethodRouter};
/// The `QUERY` method (RFC 10008). `http` 1.x has no built-in constant,
/// so this is the crate-wide extension-method singleton — compare with
/// `req.method() == &*http_query::QUERY`.
pub static QUERY: LazyLock<Method> =
LazyLock::new(|| Method::from_bytes(b"QUERY").expect("QUERY is a valid method token"));
/// Route `QUERY` requests to the given handler; every other method gets
/// `405 Method Not Allowed` with `Allow: QUERY`.
///
/// The rustango equivalent of `axum::routing::get` for the QUERY method.
/// Additional methods can be attached with [`QueryRouterExt::query`] on
/// an existing router instead — `get(list).query(search)`.
pub fn query<H, T, S>(handler: H) -> MethodRouter<S>
where
H: Handler<T, S>,
T: 'static,
S: Clone + Send + Sync + 'static,
{
MethodRouter::new().query(handler)
}
/// Adds `.query(handler)` to [`MethodRouter`], mirroring axum's own
/// `.get(…)` / `.post(…)` chaining for the RFC 10008 `QUERY` method.
pub trait QueryRouterExt<S> {
/// Route `QUERY` requests on this router to `handler`. All previously
/// registered methods (and any custom fallback) keep working; 405
/// responses gain `QUERY` in their `Allow` header. Chain this **last**
/// — see the module docs.
fn query<H, T>(self, handler: H) -> MethodRouter<S>
where
H: Handler<T, S>,
T: 'static;
}
impl<S> QueryRouterExt<S> for MethodRouter<S>
where
S: Clone + Send + Sync + 'static,
{
fn query<H, T>(self, handler: H) -> MethodRouter<S>
where
H: Handler<T, S>,
T: 'static,
{
// `any()` (rather than `.fallback()`) because it marks the outer
// router's Allow bookkeeping as "skip": the shim fully owns the
// response, including the corrected Allow header on 405s.
any(QueryShim {
inner: self,
handler,
})
}
}
/// The `any(…)` shim installed by [`QueryRouterExt::query`]: dispatches
/// `QUERY` to the user handler and delegates every other method to the
/// wrapped router. Only ever cloned where `S: Clone` (the `Handler` impl
/// below requires it), so the derived bound is always satisfied.
#[derive(Clone)]
struct QueryShim<H, S> {
inner: MethodRouter<S>,
handler: H,
}
impl<H, T, S> Handler<T, S> for QueryShim<H, S>
where
H: Handler<T, S>,
T: 'static,
S: Clone + Send + Sync + 'static,
{
type Future = Pin<Box<dyn Future<Output = Response> + Send>>;
fn call(self, req: Request, state: S) -> Self::Future {
Box::pin(async move {
// `&QUERY` deref-coerces the `LazyLock` to `&Method`.
let query: &Method = &QUERY;
if req.method() == query {
return self.handler.call(req, state).await;
}
// Not QUERY — hand the request to the wrapped router. Binding
// the state turns it into a ready-to-call service; its own
// method dispatch, custom fallback, HEAD-from-GET handling,
// and default-405 Allow bookkeeping all run unchanged.
//
// `with_state` re-boxes the wrapped router's handler endpoints
// on each call. It can't be hoisted: `state` is only known
// here, and caching a pre-stated router in the shim would alias
// one state across routers cloned before `.with_state(...)` (a
// supported axum pattern). The cost is a couple of allocations
// per non-QUERY request to a `.query()` route (tracked in #1115);
// it retires with the shim once axum routes QUERY natively
// (axum#3799).
let svc: MethodRouter<(), std::convert::Infallible> = self.inner.with_state(state);
let mut resp = match tower::ServiceExt::oneshot(svc, req).await {
Ok(resp) => resp,
Err(never) => match never {},
};
if resp.status() == StatusCode::METHOD_NOT_ALLOWED {
append_query_to_allow(resp.headers_mut());
}
resp
})
}
}
/// Add `QUERY` to a 405's `Allow` list, preserving what the wrapped
/// router reported for its native methods. Uses the same comma-no-space
/// separator axum emits (`method_routing::append_allow_header`).
fn append_query_to_allow(headers: &mut HeaderMap) {
let Some(existing) = headers.get(ALLOW) else {
// No Allow at all (e.g. a bare `query(...)` route's 405) — just
// advertise QUERY.
headers.insert(ALLOW, HeaderValue::from_static("QUERY"));
return;
};
// The empty / already-listed checks only apply when the value reads
// as text; a non-text value is never one axum produced.
if let Ok(text) = existing.to_str() {
if text.trim().is_empty() {
headers.insert(ALLOW, HeaderValue::from_static("QUERY"));
return;
}
if text
.split(',')
.any(|m| m.trim().eq_ignore_ascii_case("QUERY"))
{
return;
}
}
// Append to the raw bytes so an existing value we can't read as text
// (some custom layer's doing) is extended, never silently dropped.
let mut bytes = existing.as_bytes().to_vec();
bytes.extend_from_slice(b",QUERY");
if let Ok(v) = HeaderValue::from_bytes(&bytes) {
headers.insert(ALLOW, v);
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::routing::get;
use axum::Router;
use tower::ServiceExt;
async fn send(app: Router, method: &str, path: &str, body: &str) -> Response {
let req = Request::builder()
.method(Method::from_bytes(method.as_bytes()).unwrap())
.uri(path)
.body(Body::from(body.to_owned()))
.unwrap();
app.oneshot(req).await.unwrap()
}
async fn body_string(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn allow_header(resp: &Response) -> Vec<String> {
resp.headers()
.get(ALLOW)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.split(',')
.map(|m| m.trim().to_owned())
.filter(|m| !m.is_empty())
.collect()
}
async fn echo(body: String) -> String {
format!("query:{body}")
}
async fn list() -> &'static str {
"list"
}
#[tokio::test]
async fn query_route_reaches_handler_with_body() {
let app = Router::new().route("/search", query(echo));
let resp = send(app, "QUERY", "/search", "a=1").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_string(resp).await, "query:a=1");
}
#[tokio::test]
async fn get_and_query_coexist_on_one_route() {
let app = Router::new().route("/products", get(list).query(echo));
let resp = send(app.clone(), "GET", "/products", "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_string(resp).await, "list");
let resp = send(app, "QUERY", "/products", "q=x").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_string(resp).await, "query:q=x");
}
#[tokio::test]
async fn head_still_served_from_get_without_body() {
let app = Router::new().route("/products", get(list).query(echo));
let resp = send(app, "HEAD", "/products", "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_string(resp).await, "");
}
#[tokio::test]
async fn unmatched_method_gets_405_with_full_allow_list() {
let app = Router::new().route("/products", get(list).query(echo));
let resp = send(app, "POST", "/products", "").await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
let allow = allow_header(&resp);
assert!(allow.contains(&"GET".to_owned()), "allow: {allow:?}");
assert!(allow.contains(&"HEAD".to_owned()), "allow: {allow:?}");
assert!(allow.contains(&"QUERY".to_owned()), "allow: {allow:?}");
}
#[tokio::test]
async fn query_only_route_405s_get_with_allow_query() {
let app = Router::new().route("/search", query(echo));
let resp = send(app, "GET", "/search", "").await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(allow_header(&resp), vec!["QUERY".to_owned()]);
}
#[tokio::test]
async fn custom_fallback_on_wrapped_router_is_preserved() {
async fn custom() -> (StatusCode, &'static str) {
(StatusCode::IM_A_TEAPOT, "custom")
}
let app = Router::new().route("/x", get(list).fallback(custom).query(echo));
// QUERY still wins over the wrapped router's fallback.
let resp = send(app.clone(), "QUERY", "/x", "q").await;
assert_eq!(body_string(resp).await, "query:q");
// Other unmatched methods reach the custom fallback untouched.
let resp = send(app, "POST", "/x", "").await;
assert_eq!(resp.status(), StatusCode::IM_A_TEAPOT);
}
#[tokio::test]
async fn works_with_router_state() {
#[derive(Clone)]
struct AppState(&'static str);
async fn stateful(
axum::extract::State(s): axum::extract::State<AppState>,
body: String,
) -> String {
format!("{}:{body}", s.0)
}
let app: Router = Router::new()
.route("/s", get(list).query(stateful))
.with_state(AppState("st"));
let resp = send(app, "QUERY", "/s", "b").await;
assert_eq!(body_string(resp).await, "st:b");
}
#[tokio::test]
async fn query_method_singleton_parses() {
assert_eq!(QUERY.as_str(), "QUERY");
}
#[test]
fn append_allow_covers_absent_dedup_and_append() {
let allow = |init: Option<&str>| {
let mut h = HeaderMap::new();
if let Some(v) = init {
h.insert(ALLOW, HeaderValue::from_str(v).unwrap());
}
append_query_to_allow(&mut h);
h.get(ALLOW)
.map(|v| v.to_str().unwrap().to_owned())
.unwrap_or_default()
};
assert_eq!(allow(None), "QUERY", "absent → advertise QUERY");
assert_eq!(allow(Some("")), "QUERY", "empty → advertise QUERY");
assert_eq!(
allow(Some("GET,HEAD")),
"GET,HEAD,QUERY",
"append, no space"
);
assert_eq!(
allow(Some("GET,QUERY")),
"GET,QUERY",
"already listed → no dup"
);
assert_eq!(
allow(Some("GET, query")),
"GET, query",
"case-insensitive dedup, existing formatting preserved"
);
}
}