From 5a7f3354e4e07b1769f5a9d6215c4765470c5f5e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 18:08:39 +0300 Subject: [PATCH] feat(http): 405 + Allow when path matches other methods - Scan routers on route miss; if any method matches the path, respond 405 with ordered Allow and plain body - state::methods_matching_path; response::send_405_method_not_allowed - ASGI tests: POST-only, GET+HEAD, true 404 Closes #19 --- src/dispatch.rs | 18 +++++++++++--- src/response.rs | 17 +++++++++++++ src/state.rs | 46 +++++++++++++++++++++++++++++++++++ tests/test_405.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/test_405.py diff --git a/src/dispatch.rs b/src/dispatch.rs index 797425d..3cd641e 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -11,7 +11,7 @@ use serde_json::Value as JsonValue; use crate::params::{build_request_context, header_get_lax, parse_query, value_for_path_param}; use crate::response; use crate::schema::json_to_py; -use crate::state::{map_method_router, AppState}; +use crate::state::{map_method_router, methods_matching_path, AppState}; use crate::token::extract_bearer; fn oxyroute_debug() -> bool { @@ -131,8 +131,20 @@ pub async fn run_rsgi( let (route_idx, param_map) = match route_out { Some(x) => x, None => { - return response::send_text(&protocol, 404, "Not Found", "text/plain; charset=utf-8") - .await + let m = { + let st = state.lock().map_err(crate::lock_err)?; + methods_matching_path(&st, &path) + }; + if m.is_empty() { + return response::send_text( + &protocol, + 404, + "Not Found", + "text/plain; charset=utf-8", + ) + .await; + } + return response::send_405_method_not_allowed(&protocol, &m).await; } }; let ( diff --git a/src/response.rs b/src/response.rs index 7e90c19..49bf144 100644 --- a/src/response.rs +++ b/src/response.rs @@ -59,6 +59,23 @@ pub async fn send_bytes( }) } +/// 405 with [`Allow`][1] and a small plain body (RFC 9110 §15.5.6). +/// +/// [1]: https://www.rfc-editor.org/rfc/rfc9110#name-allow +pub async fn send_405_method_not_allowed( + protocol: &Py, + allow: &[String], +) -> PyResult { + let headers = vec![ + ("allow".to_string(), allow.join(", ")), + ( + "content-type".to_string(), + "text/plain; charset=utf-8".to_string(), + ), + ]; + send_with_headers(protocol, 405, b"Method Not Allowed", headers).await +} + /// RSGI `response_bytes` / `response_empty` with a full `[(name, value), ...]` header list. pub async fn send_with_headers( protocol: &Py, diff --git a/src/state.rs b/src/state.rs index f69e7a9..6a2e8f8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -82,3 +82,49 @@ pub fn map_method_router<'a>( _ => None, } } + +/// All HTTP methods for which `path` matches a registered route. Used to respond with **405** and +/// an [`Allow`][1] header when the request method’s router had no match but another would. +/// +/// [1]: https://www.rfc-editor.org/rfc/rfc9110#name-405-method-not-allowed +pub fn methods_matching_path(state: &AppState, path: &str) -> Vec { + const ORDER: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + let mut have = [false; 7]; + if let Ok(g) = state.get.lock() { + if g.at(path).is_ok() { + have[0] = true; + have[1] = true; + } + } + if let Ok(r) = state.post.lock() { + if r.at(path).is_ok() { + have[2] = true; + } + } + if let Ok(r) = state.put.lock() { + if r.at(path).is_ok() { + have[3] = true; + } + } + if let Ok(r) = state.patch.lock() { + if r.at(path).is_ok() { + have[4] = true; + } + } + if let Ok(r) = state.delete.lock() { + if r.at(path).is_ok() { + have[5] = true; + } + } + if let Ok(r) = state.options.lock() { + if r.at(path).is_ok() { + have[6] = true; + } + } + ORDER + .iter() + .zip(have) + .filter(|(_, ok)| *ok) + .map(|(m, _)| (*m).to_string()) + .collect() +} diff --git a/tests/test_405.py b/tests/test_405.py new file mode 100644 index 0000000..bd12d96 --- /dev/null +++ b/tests/test_405.py @@ -0,0 +1,61 @@ +"""405 Method Not Allowed with Allow when the path exists for other verbs (issue #19).""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App + + +def test_405_get_on_post_only_path() -> None: + app = App() + + @app.post("/p") + def p() -> str: + return "x" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/p") + assert r.status_code == 405, r.text + assert (r.headers.get("allow") or "").upper().find("POST") >= 0 + assert r.text == "Method Not Allowed" + + asyncio.run(_run()) + + +def test_405_post_on_get_only_includes_get_and_head() -> None: + app = App() + + @app.get("/g") + def g() -> str: + return "1" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post("/g") + assert r.status_code == 405 + allow = (r.headers.get("allow") or "").upper() + assert "GET" in allow + assert "HEAD" in allow + + asyncio.run(_run()) + + +def test_404_still_404_when_nothing_matches() -> None: + app = App() + + @app.get("/a") + def a() -> str: + return "a" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post("/nope") + assert r.status_code == 404 + + asyncio.run(_run())