Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down
17 changes: 17 additions & 0 deletions src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PyAny>,
allow: &[String],
) -> PyResult<PyObject> {
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<PyAny>,
Expand Down
46 changes: 46 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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()
}
61 changes: 61 additions & 0 deletions tests/test_405.py
Original file line number Diff line number Diff line change
@@ -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())
Loading