Skip to main content

rustango/
http_methods.rs

1//! Django-shape HTTP method restriction layer — mirrors
2//! `django.views.decorators.http.{require_http_methods, require_GET,
3//! require_POST, require_safe}`.
4//!
5//! Django uses these as decorators on view functions:
6//!
7//! ```python
8//! @require_http_methods(["GET", "POST"])
9//! def my_view(request):
10//!     ...
11//!
12//! @require_POST
13//! def submit(request):
14//!     ...
15//! ```
16//!
17//! axum routes are method-aware out of the box (`Router::route("/x",
18//! get(handler))` only ever sees GETs), so the decorator pattern
19//! looks redundant — until you have a single handler that should
20//! accept e.g. both GET and POST but not PUT/DELETE/PATCH. Mounting
21//! `MethodRestrictLayer::any_of(["GET", "POST"])` lets a single
22//! `Router::route("/x", any(handler))` enforce that without
23//! re-routing.
24//!
25//! Returns `405 Method Not Allowed` with an RFC 7231-compliant
26//! `Allow:` header listing the accepted methods (browsers and HTTP
27//! caches use the header for negotiation).
28//!
29//! ## Usage
30//!
31//! ```ignore
32//! use axum::{Router, routing::any};
33//! use rustango::http_methods::{MethodRestrictLayer, MethodRestrictRouterExt};
34//!
35//! let app: Router = Router::new()
36//!     .route("/submit", any(handler))
37//!     .require_methods(["POST"]);                 // POST only
38//!
39//! let mixed: Router = Router::new()
40//!     .route("/feed", any(handler))
41//!     .require_methods(["GET", "HEAD", "POST"]);  // multi-method view
42//!
43//! // Convenience shortcuts mirror Django's per-verb decorators.
44//! let safe_only: Router = Router::new()
45//!     .route("/health", any(handler))
46//!     .require_safe();                            // GET / HEAD / OPTIONS
47//! ```
48
49use std::sync::Arc;
50
51use axum::body::Body;
52use axum::extract::Request;
53use axum::http::{header, HeaderValue, Method, StatusCode};
54use axum::middleware::Next;
55use axum::response::Response;
56use axum::Router;
57
58/// Tower layer that gates a route on a fixed set of HTTP methods.
59/// Rejects unmatched methods with `405 Method Not Allowed` +
60/// canonical `Allow:` header. Constructed via [`Self::any_of`] or
61/// the [`MethodRestrictRouterExt`] convenience methods.
62#[derive(Clone, Debug)]
63pub struct MethodRestrictLayer {
64    allowed: Arc<Vec<Method>>,
65    /// Pre-rendered `Allow:` header value (`"GET, POST, HEAD"` etc.).
66    /// Cached so the 405 response path doesn't allocate per request.
67    allow_header: Arc<String>,
68}
69
70impl MethodRestrictLayer {
71    /// Build a layer that accepts only the given methods. Empty
72    /// list is permitted but reaches every request with 405 — used
73    /// for sanity smoke-tests.
74    #[must_use]
75    pub fn any_of<I, M>(methods: I) -> Self
76    where
77        I: IntoIterator<Item = M>,
78        M: Into<Method>,
79    {
80        let allowed: Vec<Method> = methods.into_iter().map(Into::into).collect();
81        let allow_header = allowed
82            .iter()
83            .map(Method::as_str)
84            .collect::<Vec<_>>()
85            .join(", ");
86        Self {
87            allowed: Arc::new(allowed),
88            allow_header: Arc::new(allow_header),
89        }
90    }
91
92    /// Django parity `@require_GET` — accept only GET.
93    #[must_use]
94    pub fn get_only() -> Self {
95        Self::any_of([Method::GET])
96    }
97
98    /// Django parity `@require_POST` — accept only POST.
99    #[must_use]
100    pub fn post_only() -> Self {
101        Self::any_of([Method::POST])
102    }
103
104    /// Django parity `@require_safe` — accept the cacheable / idempotent
105    /// methods (GET, HEAD, OPTIONS). RFC 7231 §4.2.1 calls these the
106    /// "safe" methods.
107    #[must_use]
108    pub fn safe_only() -> Self {
109        Self::any_of([Method::GET, Method::HEAD, Method::OPTIONS])
110    }
111}
112
113/// Extension trait — `router.require_methods([…])` reads better at
114/// the call site than `.layer(MethodRestrictLayer::any_of([…]))`.
115pub trait MethodRestrictRouterExt {
116    /// Restrict this router to the given HTTP methods.
117    #[must_use]
118    fn require_methods<I, M>(self, methods: I) -> Self
119    where
120        I: IntoIterator<Item = M>,
121        M: Into<Method>;
122
123    /// Django parity `@require_GET`.
124    #[must_use]
125    fn require_get(self) -> Self;
126
127    /// Django parity `@require_POST`.
128    #[must_use]
129    fn require_post(self) -> Self;
130
131    /// Django parity `@require_safe` — GET / HEAD / OPTIONS.
132    #[must_use]
133    fn require_safe(self) -> Self;
134}
135
136impl<S> MethodRestrictRouterExt for Router<S>
137where
138    S: Clone + Send + Sync + 'static,
139{
140    fn require_methods<I, M>(self, methods: I) -> Self
141    where
142        I: IntoIterator<Item = M>,
143        M: Into<Method>,
144    {
145        attach(self, MethodRestrictLayer::any_of(methods))
146    }
147    fn require_get(self) -> Self {
148        attach(self, MethodRestrictLayer::get_only())
149    }
150    fn require_post(self) -> Self {
151        attach(self, MethodRestrictLayer::post_only())
152    }
153    fn require_safe(self) -> Self {
154        attach(self, MethodRestrictLayer::safe_only())
155    }
156}
157
158fn attach<S: Clone + Send + Sync + 'static>(
159    router: Router<S>,
160    layer: MethodRestrictLayer,
161) -> Router<S> {
162    router.layer(axum::middleware::from_fn(
163        move |req: Request<Body>, next: Next| {
164            let layer = layer.clone();
165            async move { handle(layer, req, next).await }
166        },
167    ))
168}
169
170async fn handle(layer: MethodRestrictLayer, req: Request<Body>, next: Next) -> Response {
171    if layer.allowed.contains(req.method()) {
172        return next.run(req).await;
173    }
174    let mut resp = Response::builder()
175        .status(StatusCode::METHOD_NOT_ALLOWED)
176        .body(Body::empty())
177        .unwrap_or_else(|_| Response::new(Body::empty()));
178    if let Ok(v) = HeaderValue::from_str(&layer.allow_header) {
179        resp.headers_mut().insert(header::ALLOW, v);
180    }
181    resp
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use axum::routing::any;
188    use tower::ServiceExt;
189
190    fn app(layer: MethodRestrictLayer) -> Router {
191        attach(Router::new().route("/r", any(|| async { "ok" })), layer)
192    }
193
194    fn req(method: Method) -> Request<Body> {
195        Request::builder()
196            .method(method)
197            .uri("/r")
198            .body(Body::empty())
199            .unwrap()
200    }
201
202    #[tokio::test]
203    async fn require_methods_accepts_listed() {
204        let res = app(MethodRestrictLayer::any_of([Method::GET, Method::POST]))
205            .oneshot(req(Method::POST))
206            .await
207            .unwrap();
208        assert_eq!(res.status(), 200);
209    }
210
211    #[tokio::test]
212    async fn require_methods_rejects_unlisted_with_405() {
213        let res = app(MethodRestrictLayer::any_of([Method::GET]))
214            .oneshot(req(Method::POST))
215            .await
216            .unwrap();
217        assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
218    }
219
220    #[tokio::test]
221    async fn rejection_includes_allow_header() {
222        let res = app(MethodRestrictLayer::any_of([Method::GET, Method::POST]))
223            .oneshot(req(Method::DELETE))
224            .await
225            .unwrap();
226        let allow = res
227            .headers()
228            .get(header::ALLOW)
229            .and_then(|v| v.to_str().ok())
230            .unwrap()
231            .to_owned();
232        assert!(allow.contains("GET"));
233        assert!(allow.contains("POST"));
234        assert!(!allow.contains("DELETE"));
235    }
236
237    #[tokio::test]
238    async fn get_only_rejects_post() {
239        let res = app(MethodRestrictLayer::get_only())
240            .oneshot(req(Method::POST))
241            .await
242            .unwrap();
243        assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
244    }
245
246    #[tokio::test]
247    async fn post_only_accepts_post() {
248        let res = app(MethodRestrictLayer::post_only())
249            .oneshot(req(Method::POST))
250            .await
251            .unwrap();
252        assert_eq!(res.status(), 200);
253    }
254
255    #[tokio::test]
256    async fn safe_only_accepts_get_head_options() {
257        for m in [Method::GET, Method::HEAD, Method::OPTIONS] {
258            let res = app(MethodRestrictLayer::safe_only())
259                .oneshot(req(m.clone()))
260                .await
261                .unwrap();
262            assert_eq!(res.status(), 200, "method {m:?} should be accepted");
263        }
264    }
265
266    #[tokio::test]
267    async fn safe_only_rejects_post() {
268        let res = app(MethodRestrictLayer::safe_only())
269            .oneshot(req(Method::POST))
270            .await
271            .unwrap();
272        assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
273    }
274
275    #[tokio::test]
276    async fn router_ext_require_methods() {
277        let app: Router = Router::new()
278            .route("/r", any(|| async { "ok" }))
279            .require_methods([Method::POST]);
280        let res = app.oneshot(req(Method::GET)).await.unwrap();
281        assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
282    }
283
284    #[tokio::test]
285    async fn router_ext_require_get() {
286        let app: Router = Router::new()
287            .route("/r", any(|| async { "ok" }))
288            .require_get();
289        let ok = app.clone().oneshot(req(Method::GET)).await.unwrap();
290        assert_eq!(ok.status(), 200);
291        let bad = app.oneshot(req(Method::POST)).await.unwrap();
292        assert_eq!(bad.status(), StatusCode::METHOD_NOT_ALLOWED);
293    }
294
295    #[tokio::test]
296    async fn router_ext_require_post() {
297        let app: Router = Router::new()
298            .route("/r", any(|| async { "ok" }))
299            .require_post();
300        let bad = app.clone().oneshot(req(Method::GET)).await.unwrap();
301        assert_eq!(bad.status(), StatusCode::METHOD_NOT_ALLOWED);
302        let ok = app.oneshot(req(Method::POST)).await.unwrap();
303        assert_eq!(ok.status(), 200);
304    }
305
306    #[tokio::test]
307    async fn empty_methods_list_rejects_everything() {
308        // Edge / smoke: an empty allowlist returns 405 for every method,
309        // with an empty Allow header. Useful for sanity tests.
310        let app = app(MethodRestrictLayer::any_of(Vec::<Method>::new()));
311        let res = app.oneshot(req(Method::GET)).await.unwrap();
312        assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
313        let allow = res
314            .headers()
315            .get(header::ALLOW)
316            .and_then(|v| v.to_str().ok())
317            .unwrap();
318        assert!(allow.is_empty());
319    }
320
321    #[test]
322    fn allow_header_lists_methods_in_construction_order() {
323        let layer = MethodRestrictLayer::any_of([Method::POST, Method::GET, Method::DELETE]);
324        assert_eq!(layer.allow_header.as_str(), "POST, GET, DELETE");
325    }
326}