diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index 7f76bd0..7c3672f 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -16,7 +16,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next | 8 | [08.md](bodies/08.md) | **JWK / JWKS** — [GitHub #8](https://github.com/QueryaHub/OxyRoute/issues/8). | | 9 | [09.md](bodies/09.md) | **OpenAPI depth** (optional) — `$ref` / `$defs`. | | 21 | [21.md](bodies/21.md) | **Sub-routers** — [GitHub #46](https://github.com/QueryaHub/OxyRoute/issues/46). | -| 23 | [23.md](bodies/23.md) | **HTTPException + global exception handlers** — [GitHub #48](https://github.com/QueryaHub/OxyRoute/issues/48). | | 24 | [24.md](bodies/24.md) | **CORS helper** — [GitHub #49](https://github.com/QueryaHub/OxyRoute/issues/49). | | 28 | [28.md](bodies/28.md) | **CSRF** (optional) — [GitHub #53](https://github.com/QueryaHub/OxyRoute/issues/53). | | 29 | [29.md](bodies/29.md) | **Security headers preset** — [GitHub #54](https://github.com/QueryaHub/OxyRoute/issues/54). | @@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next - **CI / release / docs / PyO3:** 13, 14, 15, 16 - **Lifespan / `app.state`:** 18 (see `App.state`, `examples/rsgi_lifespan_app.py`, `docs/rsgi.md`) - **Form bodies:** 22 / [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — `read_form_body`, `form` / `files` kwargs, `docs/handlers.md` +- **HTTPException:** 23 / [#48](https://github.com/QueryaHub/OxyRoute/issues/48) — `oxyroute.exceptions`, `docs/handlers.md` (per-type `register_exception_handler` not in scope) ## Roadmap phasing (summary) 1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible). -2. **P1:** 8, 46, 48, 49, 53, 54; 9 as polish. +2. **P1:** 8, 46, 49, 53, 54; **#48** done — `HTTPException`; 9 as polish. 3. **Research:** 50, 51, 52 — as capacity allows. [← Back to README](README.md) diff --git a/docs/feature.md b/docs/feature.md index 77699d5..b00613f 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -47,7 +47,7 @@ | **Sub-routers / `include_router`** | Нет | Один плоский `App`; нет префиксов и вложенных без ручного дублирования путей. | | **`Mount` / static files** | Нет | Отдача `/static` из каталога — обычно отдельный слой (nginx) или Starlette StaticFiles. | | **Host-based routing** | Нет | | -| **Глобальные exception handlers** | Нет | Только 500 с JSON; нет `@app.exception_handler` и иерархии исключений. | +| **Глобальные exception handlers** | Частично | `HTTPException` → нужный статус/JSON (см. [handlers](handlers.md)); **нет** `register_exception_handler` / иерархии как в FastAPI — [#48](https://github.com/QueryaHub/OxyRoute/issues/48). | | **Middleware-цепочка** | Один хук | Нет порядка нескольких middleware и `on_request` / `on_response` как в ASGI-стеке. | --- diff --git a/docs/handlers.md b/docs/handlers.md index 21855e6..7b1bbb0 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -55,10 +55,31 @@ For precise behavior and edge cases, refer to the implementation in the reposito ## Errors in handlers and dependencies -If a **dependency factory** or the **route handler** raises a Python exception (or building the response fails), the server answers with **500** and a small **JSON** body: `{"error":"internal server error"}`. Exception text and tracebacks are **not** included in the response by default (to avoid leaking internals to clients). +### `HTTPException` (non-500 responses) + +Raise **`HTTPException`** from `oxyroute` to return a specific **status code** and JSON body without a `try` / `except` at every call site: + +```python +from oxyroute import HTTPException + +raise HTTPException(404, "not found") +raise HTTPException(422, {"errors": [...]}) # body is this JSON value +raise HTTPException(400, "bad", headers={"X-Reason": "check"}) # optional extra headers +``` + +- String or other non-`dict` / non-`list` **`detail`** becomes `{"detail": ...}`. +- **`detail=None`** uses a short default message derived from the status (HTTP reason phrase when available). +- **`Content-Type`** is set to `application/json; charset=utf-8` unless you supply a `content-type` in **`headers`**. +- Works from **route handlers**, **dependencies**, **middleware** (when they run in Python), and when **mapping the return value** to a response. + +### Other exceptions → 500 + +If a **dependency factory** or the **route handler** raises any other Python exception (or building the response fails), the server answers with **500** and a small **JSON** body: `{"error":"internal server error"}`. Exception text and tracebacks are **not** included in the response by default (to avoid leaking internals to clients). Set the environment variable **`OXYROUTE_DEBUG=1`** (or `true`) to include a **`detail`** string in that JSON for the same error and to log more at the `log` crate target **`oxyroute`** (see `RUST_LOG`, e.g. `RUST_LOG=oxyroute=error`). +There is no **`register_exception_handler`** API yet; map custom exception types by catching them in Python or by a small wrapper. + ## Pre-route hook (`set_middleware`) `App.set_middleware(f)` sets an **optional** callable taking `(scope, protocol)` (same RSGI-like objects as the rest of the stack). It runs **after** the path and method are known, **before** the request body is read or routes are matched. diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 365c39f..c847d09 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -2,7 +2,8 @@ import oxyroute._oxyroute # noqa: F401 from oxyroute._oxyroute import decode_jwt_hs from oxyroute.app import App, Depends +from oxyroute.exceptions import HTTPException from oxyroute.response import Response -__all__ = ["App", "Depends", "Response", "__version__", "decode_jwt_hs"] +__all__ = ["App", "Depends", "HTTPException", "Response", "__version__", "decode_jwt_hs"] __version__ = "0.1.0" diff --git a/oxyroute/exceptions.py b/oxyroute/exceptions.py new file mode 100644 index 0000000..e2e82fa --- /dev/null +++ b/oxyroute/exceptions.py @@ -0,0 +1,53 @@ +"""HTTP errors mapped by the native dispatcher to non-500 responses (issue #48).""" + +from __future__ import annotations + +import json +from http import HTTPStatus +from typing import Any + + +def _phrase(code: int) -> str: + try: + return HTTPStatus(code).phrase + except ValueError: + return "Error" + + +class HTTPException(Exception): + """ + Raise from a route handler or dependency to return a specific status and JSON body. + + The response body is ``application/json; charset=utf-8`` unless you set ``Content-Type`` in + ``headers``. String ``detail`` becomes ``{"detail": "..."}``; ``dict`` / ``list`` become the + root JSON value. + """ + + def __init__( + self, + status_code: int, + detail: Any = None, + *, + headers: dict[str, str] | None = None, + ) -> None: + self.status_code = int(status_code) + self.detail = detail + self.headers: dict[str, str] = dict(headers) if headers else {} + + +def _http_exception_payload( + exc: BaseException, +) -> tuple[int, bytes, list[tuple[str, str]]] | None: + """Return ``(status, body_bytes, headers)`` for Rust, or ``None`` if not an HTTPException.""" + if not isinstance(exc, HTTPException): + return None + st = exc.status_code + if not (100 <= st <= 599): + return None + if exc.detail is None: + body = json.dumps({"detail": _phrase(st)}).encode("utf-8") + elif isinstance(exc.detail, (dict, list)): + body = json.dumps(exc.detail).encode("utf-8") + else: + body = json.dumps({"detail": str(exc.detail)}).encode("utf-8") + return (st, body, list(exc.headers.items())) diff --git a/src/dispatch.rs b/src/dispatch.rs index 79a78b0..f7c9ad5 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -6,7 +6,7 @@ use parking_lot::RwLock; use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::{decode, Validation}; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict, PyList}; +use pyo3::types::{PyBytes, PyDict, PyList, PyTuple}; use serde_json::Value as JsonValue; use crate::form::{self, ParsedFile}; @@ -16,6 +16,8 @@ use crate::schema::json_to_py; use crate::state::{match_route, methods_matching_path, AppState}; use crate::token::{build_decoding_key, extract_bearer, extract_cookie_value}; +type HttpExceptionPayload = (u16, Vec, Vec<(String, String)>); + type RouteCallSnapshot = ( Py, bool, @@ -70,6 +72,68 @@ async fn send_internal_error( response::send_text(protocol, 500, &body, "application/json; charset=utf-8").await } +/// If ``err`` is :class:`oxyroute.exceptions.HTTPException`, send status/body/headers; else ``None``. +async fn try_http_exception(protocol: &Py, err: &PyErr) -> PyResult> { + let payload: Option = + Python::with_gil(|py| -> PyResult> { + let m = py.import("oxyroute.exceptions")?; + let f = m.getattr("_http_exception_payload")?; + let exc = err.value(py); + let r = f.call1((exc,))?; + if r.is_none() { + return Ok(None); + } + let tup = r.downcast::()?; + if tup.len() != 3 { + return Ok(None); + } + let status: u16 = tup.get_item(0)?.extract()?; + let b = tup.get_item(1)?; + let body: Vec = b.extract()?; + let h = tup.get_item(2)?; + let list = h.downcast::()?; + let mut headers = Vec::new(); + for i in 0..list.len() { + let item = list.get_item(i)?; + let pair = item.downcast::()?; + let k: String = pair.get_item(0)?.extract()?; + let v: String = pair.get_item(1)?.extract()?; + headers.push((k, v)); + } + Ok(Some((status, body, headers))) + })?; + let Some((st, body, mut headers)) = payload else { + return Ok(None); + }; + let has_ct = headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); + if !has_ct { + headers.insert( + 0, + ( + "content-type".to_string(), + "application/json; charset=utf-8".to_string(), + ), + ); + } + let out = response::send_with_headers(protocol, st, &body, headers).await?; + Ok(Some(out)) +} + +/// Like [`send_internal_error`], but maps :class:`HTTPException` to its status and body. +async fn send_python_error( + protocol: &Py, + method: &str, + path: &str, + err: PyErr, +) -> PyResult { + if let Some(res) = try_http_exception(protocol, &err).await? { + return Ok(res); + } + send_internal_error(protocol, method, path, err).await +} + pub async fn run_rsgi( state: Arc>, scope: Py, @@ -129,7 +193,7 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; let skip = Python::with_gil(|py| out.bind(py).is_none()); @@ -137,7 +201,7 @@ pub async fn run_rsgi( let mapped = match Python::with_gil(|py| map_handler_return(py, &out)) { Ok(m) => m, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; return send_handler_map(&protocol, is_head, mapped).await; @@ -386,7 +450,7 @@ pub async fn run_rsgi( let ct = match ct { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; if ct.as_deref().map(str::is_empty) != Some(false) { @@ -450,7 +514,7 @@ pub async fn run_rsgi( }) { Ok(o) => Some(o), Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } } } else { @@ -478,7 +542,7 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; let fut = match Python::with_gil(|py| { @@ -487,13 +551,13 @@ pub async fn run_rsgi( }) { Ok(f) => f, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; let o = match fut.await { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; dep_out.push(o); @@ -517,7 +581,7 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; dep_out.push(o); @@ -582,7 +646,7 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; let handler_out: PyObject = if run_async { @@ -592,13 +656,13 @@ pub async fn run_rsgi( }) { Ok(f) => f, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; match fut.await { Ok(x) => x, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } } } else { @@ -607,7 +671,7 @@ pub async fn run_rsgi( let mapped = match Python::with_gil(|py| map_handler_return(py, &handler_out)) { Ok(m) => m, Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e).await; } }; send_handler_map(&protocol, is_head, mapped).await diff --git a/tests/test_http_exception.py b/tests/test_http_exception.py new file mode 100644 index 0000000..0b6d7fe --- /dev/null +++ b/tests/test_http_exception.py @@ -0,0 +1,78 @@ +"""``HTTPException`` mapped to HTTP responses (issue #48).""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App, HTTPException + + +def test_http_exception_404_string_detail() -> None: + app = App() + + @app.get("/x") + def x() -> str: + raise HTTPException(404, "nope") + + 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("/x") + assert r.status_code == 404 + assert r.json() == {"detail": "nope"} + + asyncio.run(_run()) + + +def test_http_exception_dict_body() -> None: + app = App() + + @app.get("/e") + def e() -> str: + raise HTTPException(422, {"errors": [{"loc": "x", "msg": "bad"}]}) + + 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("/e") + assert r.status_code == 422 + assert r.json() == {"errors": [{"loc": "x", "msg": "bad"}]} + + asyncio.run(_run()) + + +def test_http_exception_custom_header() -> None: + app = App() + + @app.get("/h") + def h() -> str: + raise HTTPException(400, "bad", headers={"X-App": "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.get("/h") + assert r.status_code == 400 + assert r.headers.get("x-app") == "1" + assert "application/json" in (r.headers.get("content-type") or "") + + asyncio.run(_run()) + + +def test_other_exception_still_500() -> None: + app = App() + + @app.get("/b") + def b() -> str: + raise ValueError("oops") + + 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("/b") + assert r.status_code == 500 + j = r.json() + assert j.get("error") == "internal server error" + + asyncio.run(_run())