diff --git a/.github/workflows/perf-smoke.yml b/.github/workflows/perf-smoke.yml new file mode 100644 index 0000000..5b76739 --- /dev/null +++ b/.github/workflows/perf-smoke.yml @@ -0,0 +1,52 @@ +name: perf-smoke + +# Opt-in smoke for Criterion + short wrk (issue #110). Not part of default PR CI. +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + +jobs: + criterion: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Sync + Criterion smoke + run: | + uv sync --frozen --extra dev + # Quiet, short Criterion run (still builds the bench binary). + cargo bench --bench hot_path -- --sample-size 10 --warm-up-time 1 --measurement-time 2 + + wrk-scenarios: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install wrk + run: sudo apt-get update && sudo apt-get install -y wrk + - name: Build oxyroute and run short scenarios + run: | + set -euo pipefail + uv sync --frozen --extra dev --extra bench + rm -f target/wheels/oxyroute-*.whl + uv run maturin build --release + shopt -s nullglob + wheels=(target/wheels/oxyroute-*.whl) + shopt -u nullglob + uv pip install --force-reinstall "${wheels[0]}" + chmod +x perf-test/bench_scenarios.sh + OXYROUTE_BENCH_DURATION=1s OXYROUTE_BENCH_CONNECTIONS=8 OXYROUTE_BENCH_THREADS=1 \ + ./perf-test/bench_scenarios.sh diff --git a/.gitignore b/.gitignore index 1e41c46..9b041b8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ env/ # --- IDE / editor --- .idea/ +.cursor/ *.swp *~ diff --git a/AGENTS.md b/AGENTS.md index 3038ce7..d9ec7b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ # Notes for AI agents (OxyRoute) -- **Git / branches / PRs / validation** — [`.cursor/rules/git-workflow.mdc`](.cursor/rules/git-workflow.mdc) (`alwaysApply`). +- **Git / branches / PRs / validation** — [docs/development-workflow.md](docs/development-workflow.md) (integration branch `dev`, issue branches, atomic commits, PR to `dev`). - **Validation (ruff, cargo, `make test`, pytest, maturin):** the **maintainer runs these** before push. **Do not** run that full pipeline in the agent unless the user explicitly asks (e.g. “run `make test`”, “fix the failing test”). If something failed in CI or locally, the user will say so — then fix what they report. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6223b34..0ae1b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,66 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0] - 2026-07-20 + +### Added + +- First-class OpenAPI docs UI: `App(..., docs_ui="scalar"|"swagger")` and + `app.mount_docs(...)` serve CDN-backed Scalar / Swagger UI at `/docs` against + `/openapi.json` ([#130](https://github.com/QueryaHub/OxyRoute/issues/130)). +- OpenAPI enrichment for interactive explorers: matchit `:param` / `*rest` → `{param}` / + `{rest}` with path `parameters`; JWT `bearerAuth` when `require_jwt=True`; operation + `tags=` / `include_router(..., tags=[...])`; `set_openapi_info` and constructor + `openapi_description` / `openapi_contact` / `openapi_servers`. +- Granian-compatible lifespan: sync `__rsgi_init__(loop)` / `__rsgi_del__(loop)` run + `on_startup` / `on_shutdown` via `loop.run_until_complete`. Prefer overriding + `on_startup` / `on_shutdown` instead of `async def __rsgi_init__`. +- `oxyroute.testing.TestClient` for in-process HTTP tests ([#102](https://github.com/QueryaHub/OxyRoute/issues/102)). +- Typed `Request` with lazy headers ([#101](https://github.com/QueryaHub/OxyRoute/issues/101)). +- Runtime `body_model` validation with HTTP 422 ([#100](https://github.com/QueryaHub/OxyRoute/issues/100)). +- Global exception handlers for sync and async routes + ([#99](https://github.com/QueryaHub/OxyRoute/issues/99)). +- Optional request / response middleware chain + ([#98](https://github.com/QueryaHub/OxyRoute/issues/98)). +- `StaticFiles` and `App.mount` ([#104](https://github.com/QueryaHub/OxyRoute/issues/104)). +- Generic streaming responses (non-SSE chunked generators) + ([#103](https://github.com/QueryaHub/OxyRoute/issues/103)). +- Observability hooks: request id, access log, metrics + ([#127](https://github.com/QueryaHub/OxyRoute/pull/127)). +- SQLx / Postgres pool helpers on `AppState` and dynamic query execution from Python + dependencies ([#116](https://github.com/QueryaHub/OxyRoute/issues/116), + [#118](https://github.com/QueryaHub/OxyRoute/issues/118)). +- Criterion microbenchmarks (`cargo bench --bench hot_path`) and expanded wrk scenarios + (`perf-test/bench_scenarios.sh`); optional `perf-smoke` workflow + ([#110](https://github.com/QueryaHub/OxyRoute/issues/110)). + +### Changed + +- OpenAPI path keys use `{param}` form (breaking for consumers that asserted matchit + `:param` strings in `openapi_json()`). +- JWT hot path reuses prebuilt `DecodingKey` and `Validation` per route + ([#109](https://github.com/QueryaHub/OxyRoute/issues/109)). +- CORS response merge skips the Python `response_header_pairs` call when the request has + no `Origin` header ([#108](https://github.com/QueryaHub/OxyRoute/issues/108)). +- OpenAPI document string is cached until the next registration change + ([#129](https://github.com/QueryaHub/OxyRoute/pull/129)). +- Router / dispatch hot-path improvements: fewer path-param allocations, sync short-circuit + for trivial routes, direct `json_to_py`, cheaper str/bytes responses, env-flag caching + ([#94](https://github.com/QueryaHub/OxyRoute/issues/94)–[#97](https://github.com/QueryaHub/OxyRoute/issues/97), + [#128](https://github.com/QueryaHub/OxyRoute/pull/128)). + +### Migration + +- Prefer `async def on_startup` / `on_shutdown` for worker lifecycle under Granian. Do not + override `__rsgi_init__` as `async def` (the coroutine is never awaited). +- Update any tooling that expected OpenAPI paths with matchit `:param` syntax to `{param}`. +- Interactive docs: `App(..., docs_ui="scalar")` (or `mount_docs`) instead of app-local HTML. + +## [0.4.0] - 2026-05 + +RSGI-only line with native WebSockets, forms, CORS/CSRF/security headers, and related +hardening after the v0.3.0 ASGI removal. See `git log v0.3.0..v0.4.0` for the full list. + ## [0.3.0] - 2026-04-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index 37001ca..c0e480c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "atoi" version = "2.0.0" @@ -74,6 +95,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.61" @@ -101,6 +128,58 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" @@ -149,6 +228,61 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -164,6 +298,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -450,6 +590,17 @@ dependencies = [ "rand_core", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -482,6 +633,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -653,6 +810,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -827,12 +1004,19 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "oxyroute" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", "bytes", + "criterion", "form_urlencoded", "futures-util", "jsonwebtoken", @@ -907,6 +1091,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1044,6 +1256,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1053,6 +1285,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -1107,6 +1368,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1546,6 +1816,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1693,6 +1973,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1744,6 +2034,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -1759,6 +2059,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1876,6 +2185,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index f19abdc..0dd9a57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxyroute" -version = "0.4.0" +version = "0.5.0" edition = "2021" description = "RSGI web framework: Rust hot path, Python handlers" license = "MIT" @@ -8,10 +8,11 @@ repository = "https://github.com/QueryaHub/OxyRoute" [lib] name = "_oxyroute" -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] -pyo3 = { version = "0.25", features = ["abi3-py310", "extension-module", "auto-initialize", "py-clone"] } +# `extension-module` is a Cargo feature (enabled by maturin) so `cargo bench` can link libpython. +pyo3 = { version = "0.25", features = ["abi3-py310", "auto-initialize", "py-clone"] } pyo3-async-runtimes = { version = "0.25", features = ["tokio-runtime"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] } matchit = "0.7" @@ -30,8 +31,17 @@ log = "0.4" parking_lot = "0.12" sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "sqlite", "tls-rustls"] } +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "hot_path" +harness = false + [features] default = [] +# Enabled by maturin when building the wheel; omit for `cargo bench` / `cargo test` so libpython links. +extension-module = ["pyo3/extension-module"] [profile.release] lto = true diff --git a/README.md b/README.md index 40b5cab..c5f443f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ High-performance web framework for **Granian RSGI**, tuned for high **single-wor - **Routing** via [matchit](https://crates.io/crates/matchit) (path parameters like `/users/:id`) - **JSON, form, and multipart bodies** parsed on the native path; successful values passed to handlers as kwargs - **JWT** verification on the Rust path before your handler runs (`require_jwt`, HS*, RSA, EC, EdDSA public-key verification) -- **Optional** `GET /openapi.json` with a minimal OpenAPI-style document +- **OpenAPI** `GET /openapi.json` plus optional Scalar/Swagger UI at `/docs` - **Dependencies**: linear list of named factories (`Depends`, sync or async) passed as kwargs - **Optional middleware layers** for pre-route decisions, CORS, CSRF, and browser security headers - **Native RSGI WebSockets** via `@app.websocket(path)` and `oxyroute.WebSocket` @@ -72,9 +72,9 @@ Run (from the repo, after `maturin develop` or an editable install): granian --interface rsgi examples.rsgi_app:app ``` -Per-worker setup (`__rsgi_init__`) is shown in [examples/rsgi_lifespan_app.py](examples/rsgi_lifespan_app.py) and [docs/rsgi.md](docs/rsgi.md#lifespan-optional). +Per-worker setup (`on_startup` / Granian-compatible `__rsgi_init__`) is shown in [examples/rsgi_lifespan_app.py](examples/rsgi_lifespan_app.py) and [docs/rsgi.md](docs/rsgi.md#lifespan-optional). -OxyRoute v0.3.0 supports **only** Granian RSGI; the legacy ASGI bridge (`uvicorn` / `granian --interface asgi`) was removed. +OxyRoute supports **only** Granian RSGI; the legacy ASGI bridge (`uvicorn` / `granian --interface asgi`) was removed in v0.3.0. ## Usage docs diff --git a/benches/hot_path.rs b/benches/hot_path.rs new file mode 100644 index 0000000..3db1c74 --- /dev/null +++ b/benches/hot_path.rs @@ -0,0 +1,92 @@ +//! Criterion microbenchmarks for hot-path primitives (issue #110). +//! +//! Run from the repo root (requires a linked Python interpreter via PyO3):: +//! +//! ```bash +//! cargo bench --bench hot_path +//! ``` +//! +//! Not part of default CI. Collect a baseline before perf PRs and attach Criterion +//! HTML reports (`target/criterion/`) or key numbers in the PR description. + +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyString}; +use serde_json::json; + +use _oxyroute::microbench::{ + json_to_py, map_handler_return_status, match_route_compiled, sample_compiled_routers, +}; + +fn bench_match_route(c: &mut Criterion) { + let compiled = sample_compiled_routers(); + let mut group = c.benchmark_group("match_route_compiled"); + group.bench_function("static", |b| { + b.iter(|| { + let hit = match_route_compiled(black_box(&compiled), "GET", black_box("/hello")); + black_box(hit) + }) + }); + group.bench_function("param", |b| { + b.iter(|| { + let hit = match_route_compiled(black_box(&compiled), "GET", black_box("/items/42")); + black_box(hit) + }) + }); + group.finish(); +} + +fn bench_map_handler_return(c: &mut Criterion) { + Python::with_gil(|py| { + let s = PyString::new(py, "hello world"); + let buf = PyBytes::new(py, b"hello world"); + let mut group = c.benchmark_group("map_handler_return"); + group.bench_function("str", |b| { + b.iter(|| { + let status = map_handler_return_status(py, black_box(s.as_any())).unwrap(); + black_box(status) + }) + }); + group.bench_function("bytes", |b| { + b.iter(|| { + let status = map_handler_return_status(py, black_box(buf.as_any())).unwrap(); + black_box(status) + }) + }); + group.finish(); + }); +} + +fn bench_json_to_py(c: &mut Criterion) { + let small = json!({"a": 1, "b": "x", "c": true}); + let nested = json!({ + "items": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + "meta": {"ok": true, "n": 2} + }); + Python::with_gil(|py| { + let mut group = c.benchmark_group("json_to_py"); + group.bench_function("small_object", |b| { + b.iter(|| { + let obj = json_to_py(py, black_box(&small)).unwrap(); + black_box(obj) + }) + }); + group.bench_function("nested", |b| { + b.iter(|| { + let obj = json_to_py(py, black_box(&nested)).unwrap(); + black_box(obj) + }) + }); + group.finish(); + }); +} + +criterion_group!( + benches, + bench_match_route, + bench_map_handler_return, + bench_json_to_py +); +criterion_main!(benches); diff --git a/docs/dependencies.md b/docs/dependencies.md index 37403c5..d0106be 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -6,7 +6,12 @@ OxyRoute supports a **linear** list of **named** dependency factories. At reques ### Request context (optional) -If a factory’s signature includes a parameter named `request`, the extension passes a **dict** (once per request, shared) with string keys: `method`, `path`, `query_string`, and `headers` (a flat `str` → `str` map, when the underlying RSGI scope exposes headers). Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior. +If a factory’s signature includes a parameter named `request`, the extension passes an `oxyroute.Request` object (once per request, shared). The `Request` object has typed accessors: `method`, `path`, `query_string`, `headers`, `client`, and `cookies`. For backwards compatibility, it can still be accessed like a dictionary (e.g. `request["headers"]`). + +### Lazy Headers +To avoid performance overhead when headers are not needed by the handler or dependencies, the `Request.headers` dict is populated lazily. The full header map is only built on the first access of `.headers` (or `request["headers"]`). + +Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior. ## Declaring on a route diff --git a/docs/development.md b/docs/development.md index 696db50..f2ff917 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,44 +2,46 @@ [← Documentation index](index.md) -## Rust +## Local Development & Tests -```bash -cargo build -cargo clippy -``` - -Release settings are in `Cargo.toml` (`lto`, `codegen-units`). - -## Python extension (Maturin) +The project uses `uv` for dependency management and a `Makefile` to handle building the Rust extension and running tests safely. You do not need to manually run `maturin develop` or create virtual environments. ```bash -maturin develop -# or -maturin build --release +# Install dependencies, build the extension, and run all linters & tests +make test ``` -See [installation.md](installation.md) for venvs and the `patchelf` note on some Linux systems. +**Shadowing the installed package:** If you run `pytest` directly from the repository root, Python might import the raw source tree `oxyroute/` without the compiled `._oxyroute` binary, causing failures. +The `make test` and `make pytest` commands automatically run tests from a temporary directory to avoid this issue. -## Tests +### Other useful commands: +- `make lint` — Run `ruff` and `cargo clippy/fmt` checks. +- `make fix` — Auto-format code with `ruff format` and `cargo fmt`. +- `make develop` — Build the Rust extension into `.venv` without running tests. -The suite uses **pytest** and is configured in `pyproject.toml` with `testpaths = ["tests"]`. +## Writing Application Tests -**Shadowing the installed package:** If you run `pytest` from the **repository root**, Python can import the **source tree** `oxyroute/` (without a rebuilt `._oxyroute` binary) and fail in confusing ways. The CI job runs from a **temporary directory** and points pytest at the workspace tests, so the **installed** wheel is imported. +OxyRoute ships with an integrated `TestClient` for writing synchronous HTTP tests against your application without needing to start a real server. -**Locally**, either: +```python +from oxyroute import App +from oxyroute.testing import TestClient -- `cd` to a different directory and run: - `python -m pytest /path/to/OxyRoute/tests` - after `pip install` / `maturin develop` in your environment, or -- `pip install -e` / install the wheel, then use a **clean** working directory for pytest. +app = App() -**Optional dev extra:** +@app.get("/") +def home(): + return {"status": "ok"} -```bash -pip install "oxyroute[dev]" # in your dev env, from source after maturin develop +def test_home(): + with TestClient(app) as client: + resp = client.get("/") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} ``` +Using `with TestClient(app)` ensures that the application's `__rsgi_init__` and `__rsgi_del__` lifespan hooks are run synchronously. + ## Granian RSGI (end-to-end) `tests/test_granian_e2e.py` starts a real **Granian** subprocess with `--interface rsgi`, sends HTTP requests with **httpx**, then stops the server. It is part of the normal **pytest** run when `granian` is installed (`oxyroute[dev]` includes it). The same file runs in **CI** on every matrix combination (Linux, macOS, Windows), so the native RSGI path is exercised against a real server, not only the in-process httpx test transport. @@ -53,11 +55,11 @@ The workflow at `.github/workflows/ci.yml` (job name: **ci**): ## Releasing to PyPI -Tag a release with a **`v`-prefixed** semver tag (example: **`v0.3.0`**). That triggers `.github/workflows/release-pypi.yml`, which builds an **sdist**, **manylinux** x86_64 wheels, **Windows** x64, and **macOS** arm64 + x86_64 wheels, then uploads to **PyPI** using a **project-scoped API token** stored in GitHub as **`PYPI_API_TOKEN`** (Secret or Environment variable) on the **`pypi`** environment. The publish step uses `secrets` first, then `vars` (so you can start with a variable and move the value to a **Secret** later). +Tag a release with a **`v`-prefixed** semver tag (example: **`v0.5.0`**). That triggers `.github/workflows/release-pypi.yml`, which builds an **sdist**, **manylinux** x86_64 wheels, **Windows** x64, and **macOS** arm64 + x86_64 wheels, then uploads to **PyPI** using a **project-scoped API token** stored in GitHub as **`PYPI_API_TOKEN`** (Secret or Environment variable) on the **`pypi`** environment. The publish step uses `secrets` first, then `vars` (so you can start with a variable and move the value to a **Secret** later). **Before the first upload:** -1. Keep **`pyproject.toml`**, **`Cargo.toml`**, and **`oxyroute/__init__.py`** `__version__` in sync with the version you are releasing, and with the tag (e.g. `0.3.0` → tag `v0.3.0`). +1. Keep **`pyproject.toml`**, **`Cargo.toml`**, and **`oxyroute/__init__.py`** `__version__` in sync with the version you are releasing, and with the tag (e.g. `0.5.0` → tag `v0.5.0`). 2. On [PyPI](https://pypi.org), create a **scoped API token** for this project, then in GitHub → **Settings → Environments** create the **`pypi`** environment and add **`PYPI_API_TOKEN`** (strongly prefer an **Environment secret** over a **Variable**; tokens in Variables are visible to people with access to the environment). 3. Optional alternative to API tokens: [trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) — no long-lived token; then the workflow’s publish job should omit `with.password` and set `id-token: write` (see the PyPA action README). diff --git a/docs/feature.md b/docs/feature.md index a04304e..0099abb 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -27,7 +27,7 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| | **WebSockets** | Реализованы | Native RSGI WebSocket: `@app.websocket(path)`, `oxyroute.WebSocket`; см. [websocket.md](websocket.md). Нет high-level subprotocol API. | -| **SSE / длинный стрим ответа** | Частично | Есть `send_sse` (см. [sse.md](sse.md)); инкрементальный стрим использует `response_stream` Granian RSGI. | +| **SSE / длинный стрим ответа** | Частично | Есть `send_sse` (см. [streaming.md](streaming.md)); инкрементальный стрим использует `response_stream` Granian RSGI. | | **HTTP/2 push, trailers** | Не в фокусе | Обычно на стороне сервера; фреймворк редко экспонирует. | | **ASGI совместимость** | Удалена в v0.3.0 | Поддерживается только RSGI (Granian `--interface rsgi`). | diff --git a/docs/handlers.md b/docs/handlers.md index f2fdda8..50a6838 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -98,4 +98,4 @@ For a configurable **`allow_origins` / `allow_methods` / `allow_headers`** flow - [CORS](cors.md) - [Security headers](security-headers.md) - [CSRF](csrf.md) -- [SSE](sse.md) +- [SSE](streaming.md) diff --git a/docs/index.md b/docs/index.md index e701419..25f6d1c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,11 +46,11 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [Security headers](security-headers.md) | `SecurityHeadersConfig`, HSTS, CSP | | [CSRF](csrf.md) | Double-submit, `apply_csrf`, `csrf_layer` + CORS | | [JWT](jwt.md) | `require_jwt`, HS* / RSA / EC PEM, `decode_jwt_hs` (HS* tests) | -| [SSE](sse.md) | `send_sse`, event framing, streaming caveats | +| [Streaming & SSE](streaming.md) | `stream_bytes`, `stream_text`, `send_sse`, streaming caveats | | [WebSockets](websocket.md) | Native RSGI `@app.websocket(path)` and `oxyroute.WebSocket` | | [HTTP/2 with Granian](http2.md) | Transport guarantees vs server/proxy responsibilities | | [Dependencies](dependencies.md) | `Depends`, `dependencies=[...]`, `freeze` | -| [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` | +| [OpenAPI](openapi.md) | `openapi.json`, docs UI (Scalar/Swagger), tags, JWT security | | [Development](development.md) | Tests, CI, PyPI releases (tag `v*`), clippy, pytest | | [Branching and PRs](development-workflow.md) | `dev` as base, issue branches, `Closes #N`, no mixing code with `ISSUE_BACKLOG` in one commit | | [Feature gaps (research)](feature.md) | What is missing vs a “full” HTTP framework and what has been implemented — Russian | diff --git a/docs/openapi.md b/docs/openapi.md index 2e974b0..eb9237a 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -2,7 +2,7 @@ [← Documentation index](index.md) -OxyRoute maintains a small **OpenAPI 3.0**-shaped JSON document in Rust while routes are registered. It is **not** a full OpenAPI model of every type and body schema; it is a **minimal** view suitable for discovery and tooling, and can be extended in future versions. +OxyRoute maintains an **OpenAPI 3.0**-shaped JSON document in Rust while routes are registered. It is suitable for discovery and interactive docs (Scalar / Swagger UI), and can be extended further in future versions. ## Constructor and toggles @@ -15,16 +15,55 @@ Served vs built: one flag, two ways to set it. - **While serving is off** (`include_openapi=False` at build time, or after `set_openapi_served(False)`): - The engine does **not** return the spec for **`GET /openapi.json`** or **`HEAD /openapi.json`**. The request is **not** special-cased, so it goes through normal routing. Unless you add your own handler for that path, the client usually gets **404 Not Found**. If you **do** register a handler for `/openapi.json`, that handler can serve a custom response. - Route registration still **merges** operations into the in-memory OpenAPI document. The document is not discarded. -- **Export:** **`openapi_json()`** on the Python `App` still returns the current JSON as a string (handy in tests, admin tools, or a custom response), regardless of the serving toggle. +- **Export:** **`openapi_json()`** on the Python `App` still returns the current JSON as a string (handy in tests, admin tools, or a custom response), regardless of the serving toggle. The exported document is the **same** enriched spec the UI uses. -## Title and export +## Docs UI (Scalar / Swagger) -- **`set_openapi_title`** is applied from `App(..., title="...")` at construction. -- **`openapi_json()`** is described above; it is independent of whether `/openapi.json` is exposed over HTTP. +Optional interactive explorer (CDN-backed HTML): + +```python +from oxyroute import App + +app = App(title="My API", docs_ui="scalar") # or "swagger" +# → GET /docs + +# or later / custom path: +app.mount_docs("/api/docs", ui="swagger") +``` + +| Option | Meaning | +|--------|---------| +| `docs_ui="scalar"` \| `"swagger"` | Mount `GET /docs` at construction | +| `mount_docs(path, ui=...)` | Mount at a custom path | +| Spec URL | Built-in `/openapi.json` | + +UI scripts load from **jsDelivr**. If you use `SecurityHeadersConfig` (or a strict CSP), allow `cdn.jsdelivr.net` in `script-src` / `style-src` for the docs route, or disable those headers on `/docs`. + +## Title, info, and servers + +- **`title=`** / **`set_openapi_title`** — `info.title`. +- **`openapi_description=`**, **`openapi_contact=`**, **`openapi_servers=`** constructor kwargs, or **`app.set_openapi_info(description=..., contact=..., servers=...)`**. + +```python +app = App( + title="Market API", + openapi_description="Public marketplace HTTP API", + openapi_contact={"name": "API", "email": "api@example.com"}, + openapi_servers=[{"url": "https://api.example.com"}], + docs_ui="scalar", +) +``` ## What is in the document today -Per route, the code records path, method, a short `summary` / `operationId` derived from the **handler’s** `__name__`, and a simple `200` response placeholder. +Per route, the code records: + +- OpenAPI path templates: matchit **`:id` → `{id}`**, catch-all **`*rest` → `{rest}`** +- Path **`parameters`** (`in: path`, `required: true`, string schema) +- Method, short `summary` / `operationId` from the handler’s `__name__` +- Simple `200` response placeholder +- Optional **`tags`** from the route decorator or `include_router(..., tags=[...])` (per-route `tags=` wins over include defaults) +- If **`require_jwt=True`**: `components.securitySchemes.bearerAuth` (`http` + `bearer` + `JWT`) and `security: [{ bearerAuth: [] }]` on that operation For **`POST`**, **`PUT`**, and **`PATCH`**, you can document the JSON request body in OpenAPI in two ways (pass **at most one**): @@ -35,3 +74,4 @@ For **`POST`**, **`PUT`**, and **`PATCH`**, you can document the JSON request bo - [Routing](routing.md) - [Handlers](handlers.md) +- [RSGI / lifespan](rsgi.md) diff --git a/docs/rsgi.md b/docs/rsgi.md index e705cb6..4cd5b07 100644 --- a/docs/rsgi.md +++ b/docs/rsgi.md @@ -18,20 +18,29 @@ The Python `oxyroute.app.App` class implements the async RSGI entry that Granian ## Lifespan (optional) -`App` defines no-op coroutines for servers that expect them. Implementations use `*args, **kwargs` so **Granian** (and any server that passes extra parameters to worker lifespan hooks) can call them without a `TypeError`: +Granian’s RSGI worker calls **sync** lifespan hooks with a **non-running** event loop: -- `async def __rsgi_init__(self, *args, **kwargs) -> None` — per-worker (or per-process) **startup** in the RSGI host -- `async def __rsgi_del__(self, *args, **kwargs) -> None` — **teardown** when the worker stops +```python +def __rsgi_init__(self, loop): + loop.run_until_complete(...) +``` + +OxyRoute’s base `App` implements that contract. Prefer overriding the async helpers: + +- **`async def on_startup(self) -> None`** — per-worker startup (DB pools, clients, …) +- **`async def on_shutdown(self) -> None`** — teardown (base closes the SQLx pool if any) + +The framework’s sync **`__rsgi_init__(loop)`** / **`__rsgi_del__(loop)`** call `loop.run_until_complete` on those coroutines. When called **without** a loop (tests / `TestClient`), they **return** the coroutine so callers can `await` it. -You can **override** these in a **subclass** of `App` to open DB pools, HTTP clients, `asyncio` primitives, etc. The default base implementation does nothing. +**Warning:** Do not override `__rsgi_init__` as `async def`. Under Granian the coroutine is never awaited (`coroutine was never awaited`), so pools never open. Override **`on_startup`** / **`on_shutdown`** instead. -Every `App` exposes **`app.state`**, a `types.SimpleNamespace` for attaching **per-process** objects. Use it in `__rsgi_init__` (or a factory) instead of ad hoc attributes on `self` if you want a single obvious place for shared services; it is the same not-shared-across-processes story as any other in-memory `App` data. +Every `App` exposes **`app.state`**, a `types.SimpleNamespace` for attaching **per-process** objects. Use it in `on_startup` (or a factory) instead of ad hoc attributes on `self` if you want a single obvious place for shared services; it is the same not-shared-across-processes story as any other in-memory `App` data. ### Workers and shared state (Granian) -- With **`granian --workers N`**, the server runs **N independent worker processes** (typical for CPU-bound HTTP). Each process loads your module, constructs your `app`, and may call `__rsgi_init__` **once per worker** (exact call pattern is defined by the server; see [Granian’s docs](https://github.com/emmett-framework/granian)). **In-memory** attributes you set in `__rsgi_init__` are **not** shared between workers: two requests may hit different processes and see different `self.foo`. +- With **`granian --workers N`**, the server runs **N independent worker processes** (typical for CPU-bound HTTP). Each process loads your module, constructs your `app`, and may call `__rsgi_init__` **once per worker** (exact call pattern is defined by the server; see [Granian’s docs](https://github.com/emmett-framework/granian)). **In-memory** attributes you set in `on_startup` are **not** shared between workers: two requests may hit different processes and see different `self.foo`. - If you use **a single worker** or run under **in-process** tests, one process is enough for a module-level or `self` cache for development only. -- For **user sessions, counts, or singletons** across the whole deployment, use **external** storage (Postgres, Redis, etc.); a DB **connection pool** created in `__rsgi_init__` is still a good pattern: one pool **per process**, many requests share connections inside that pool. +- For **user sessions, counts, or singletons** across the whole deployment, use **external** storage (Postgres, Redis, etc.); a DB **connection pool** created in `on_startup` is still a good pattern: one pool **per process**, many requests share connections inside that pool. ### Factory pattern @@ -51,9 +60,10 @@ app = create_app() ### Example in the repository - [`examples/rsgi_app.py`](../examples/rsgi_app.py) — minimal RSGI app -- [`examples/rsgi_lifespan_app.py`](../examples/rsgi_lifespan_app.py) — subclass with `__rsgi_init__` / `__rsgi_del__` and `ready_at` used from handlers +- [`examples/rsgi_lifespan_app.py`](../examples/rsgi_lifespan_app.py) — subclass with `on_startup` / `on_shutdown` and `ready_at` used from handlers ## See also - [Handlers](handlers.md) — what the Rust core passes into your functions - [Routing](routing.md) — how paths are matched +- [OpenAPI](openapi.md) — docs UI and enriched `/openapi.json` diff --git a/docs/sse.md b/docs/sse.md deleted file mode 100644 index ab4caeb..0000000 --- a/docs/sse.md +++ /dev/null @@ -1,44 +0,0 @@ -# Server-Sent Events (SSE) - -[← Documentation index](index.md) - -OxyRoute provides a small SSE helper in `oxyroute.sse` for HTTP event streams. - -## Quick start - -```python -from oxyroute import App, send_sse - -app = App() - - -@app.get("/events") -async def events(protocol): - return await send_sse(protocol, ["ready", "tick"]) -``` - -## API - -- `send_sse(protocol, events, *, status=200, headers=None)`: - - sets `content-type: text/event-stream; charset=utf-8`, - - formats items as SSE frames (`data: ...\n\n`), - - returns a sentinel consumed by OxyRoute so no second response is emitted. -- Event items can be: - - `str` (serialized as `data: `), - - `SSEEvent(data=..., event=..., id=..., retry=...)`. - -## Streaming behavior - -- On RSGI protocols exposing `response_stream` (Granian RSGI), chunks are written incrementally. -- On transports without streaming support (e.g. the in-process httpx test transport), OxyRoute falls back to one buffered `response_str` body with SSE framing. - -## Caveats - -- Browser/proxy buffering can delay event delivery unless buffering is disabled at the edge. -- SSE is one-way server-to-client messaging over HTTP; use WebSockets for bi-directional flows. -- HTTP/1.1 and HTTP/2 transport negotiation is server/proxy responsibility; SSE framing itself is unchanged. - -## See also - -- [Handlers](handlers.md) -- [HTTP/2 with Granian](http2.md) diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..542a74a --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,56 @@ +# Streaming Responses + +[← Documentation index](index.md) + +OxyRoute provides streaming response helpers in `oxyroute.streaming` for returning chunked HTTP responses without buffering the entire body in memory. Server-Sent Events (SSE) are available in `oxyroute.sse`. + +## Quick start + +```python +import asyncio +from oxyroute import App, stream_text + +app = App() + +@app.get("/logs") +async def logs(protocol): + async def tail_logs(): + for i in range(5): + yield f"Log line {i}\n" + await asyncio.sleep(1) + + # `stream_text` formats chunks as `text/plain` + return await stream_text(protocol, tail_logs()) +``` + +## Available Helpers + +All helpers require the `protocol` argument and an iterable or async iterable of data. + +- `stream_bytes(protocol, iterable, *, status=200, headers=None, content_type="application/octet-stream")` + Streams raw `bytes`. Useful for file downloads and proxying binary streams. + +- `stream_text(protocol, iterable, *, status=200, headers=None, content_type="text/plain; charset=utf-8")` + Streams `str` chunks. + +- `stream_jsonl(protocol, iterable, *, status=200, headers=None)` + Takes an iterable of dicts/lists/objects and streams them as NDJSON (JSON-Lines) with `content-type: application/x-ndjson; charset=utf-8`. + +- `send_sse(protocol, events, *, status=200, headers=None)` + Streams items as Server-Sent Events with `content-type: text/event-stream; charset=utf-8`. + +## Behavior & Backpressure + +- On RSGI servers supporting `response_stream` (like Granian), chunks are sent incrementally. +- Awaiting the streaming helpers automatically respects TCP backpressure. If the client is slow to read, Granian pauses the underlying stream, causing `await stream.send_bytes(...)` to block, which in turn pauses your async generator. +- On transports without streaming support (e.g., the integrated test client or ASGI bridging), OxyRoute falls back to buffering all chunks into memory and returning a single response. + +## Caveats + +- Browser or intermediate proxy buffering can delay chunk delivery unless buffering is explicitly disabled at the edge. +- Handlers returning streams **must** be `async def` and must `await` the streaming helper, because the helpers themselves run async I/O. + +## See also + +- [Handlers](handlers.md) +- [HTTP/2 with Granian](http2.md) diff --git a/docs/usage.md b/docs/usage.md index 567d756..3c784df 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,7 +3,7 @@ [← Documentation index](index.md) This guide is the recommended end-to-end reference for using OxyRoute as an -application framework. It describes the current **v0.3.0** behavior: OxyRoute is +application framework. It describes the current **v0.5.0** behavior: OxyRoute is **RSGI-only** and is intended to run behind **Granian** with `--interface rsgi`. The removed ASGI bridge is not part of the supported runtime path. @@ -79,7 +79,7 @@ See [installation.md](installation.md) for troubleshooting native builds. ```python from oxyroute import App -app = App(title="My API", include_openapi=True) +app = App(title="My API", include_openapi=True, docs_ui="scalar") ``` Constructor options: @@ -88,6 +88,8 @@ Constructor options: |---|---:|---| | `title` | `"OxyRoute"` | Stored in the generated OpenAPI document. | | `include_openapi` | `True` | Serve built-in `GET` / `HEAD /openapi.json`. | +| `docs_ui` | `None` | `"scalar"` or `"swagger"` → mount interactive `GET /docs`. | +| `openapi_description` / `openapi_contact` / `openapi_servers` | `None` | Enrich OpenAPI `info` / `servers`. | Runtime methods: @@ -96,6 +98,8 @@ Runtime methods: | `app.freeze()` | Reject new route registrations and build a read-only routing snapshot. The app also auto-builds this snapshot on first request if you do not call `freeze()`. | | `app.set_openapi_served(False)` | Stop serving built-in `/openapi.json`; the in-memory document still exists. | | `app.openapi_json()` | Return the current OpenAPI JSON string even when serving is disabled. | +| `app.set_openapi_info(...)` | Set `info.description` / `contact` / `servers`. | +| `app.mount_docs(path, ui=...)` | Mount Scalar or Swagger UI at a custom path. | | `app.set_middleware(fn_or_none)` | Enable or disable one optional pre-route callback. | | `app.set_cors(config_or_none)` | Enable or disable CORS header merging. | | `app.set_security_headers(config_or_none)` | Enable or disable browser security header merging. | @@ -465,7 +469,7 @@ async def events(protocol): return await send_sse(protocol, [SSEEvent(data="hello")]) ``` -See [sse.md](sse.md) for details and caveats. +See [streaming.md](streaming.md) for details and caveats. ## OpenAPI @@ -488,17 +492,18 @@ or `body_schema=` on `post`, `put`, and `patch` routes. ## Lifespan and per-worker state -Subclass `App` when you need per-worker setup/teardown: +Subclass `App` and override **`on_startup` / `on_shutdown`** (Granian calls sync +`__rsgi_init__(loop)` with a non-running loop — do not use `async def __rsgi_init__`): ```python from oxyroute import App class MyApp(App): - async def __rsgi_init__(self, *args, **kwargs) -> None: + async def on_startup(self) -> None: self.state.ready = True - async def __rsgi_del__(self, *args, **kwargs) -> None: + async def on_shutdown(self) -> None: self.state.ready = False @@ -506,7 +511,7 @@ app = MyApp() ``` `app.state` is a `types.SimpleNamespace`. It is per process, not shared between -Granian workers. +Granian workers. See [rsgi.md](rsgi.md). ## Recommended production shape @@ -560,12 +565,9 @@ Production checklist: - Keep `OXYROUTE_DEBUG` unset in production. - Use external storage for cross-worker state. -## Known limitations in v0.3.0 +## Known limitations in v0.5.0 - Request bodies and multipart files are buffered in memory before parsing. -- There is one pre-route middleware hook; compose middleware manually or with - helpers such as `apply_cors(..., chain=...)`. -- There is no global exception-handler registry yet. - WebSocket subprotocol negotiation is not exposed as a high-level API. - Benchmark scripts are for local comparison and are not CI performance gates. diff --git a/examples/rsgi_lifespan_app.py b/examples/rsgi_lifespan_app.py index 6deed60..b3a914c 100644 --- a/examples/rsgi_lifespan_app.py +++ b/examples/rsgi_lifespan_app.py @@ -1,5 +1,5 @@ """ -Per-worker RSGI lifecycle: override ``__rsgi_init__`` / ``__rsgi_del__`` (issue #18). +Per-worker RSGI lifecycle: override ``on_startup`` / ``on_shutdown`` (issue #18 / #130). Run (from the repo root after an editable / wheel install):: @@ -8,7 +8,12 @@ ``examples/rsgi_app.py`` is the minimal app. This file shows **subclassing** ``App`` to open resources when the host starts a worker, using :attr:`oxyroute.app.App.state` and a :func:`concurrent.futures.ThreadPoolExecutor` (typical for blocking I/O in sync handlers; -use ``asyncio`` primitives in ``__rsgi_init__`` when your stack is natively async). +use ``asyncio`` primitives in ``on_startup`` when your stack is natively async). + +**Granian** calls sync ``__rsgi_init__(loop)`` / ``__rsgi_del__(loop)`` with a +**non-running** loop. The base ``App`` runs ``on_startup`` / ``on_shutdown`` via +``loop.run_until_complete``. Do **not** override ``__rsgi_init__`` as ``async def`` — +that coroutine is never awaited under Granian. In-memory data is **per OS process**; with ``granian --workers N`` each worker has its own object graph — use Redis, a DB pool, or a message bus for **cross-worker** or @@ -29,7 +34,7 @@ class LifespanApp(App): - """Example: attach per-process state when the RSGI worker calls ``__rsgi_init__``.""" + """Example: attach per-process state when the RSGI worker calls ``on_startup``.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -37,7 +42,7 @@ def __init__(self, *args, **kwargs): self.state.ready_at = None self.state.thread_pool = None - async def __rsgi_init__(self, *args, **kwargs) -> None: + async def on_startup(self) -> None: # ``asyncio`` primitive example (use from async callables you control). self.state.bg_limit = asyncio.Semaphore(8) # Thread pool: run blocking work via ``run_in_executor(self.state.thread_pool, ...)`` @@ -47,9 +52,8 @@ async def __rsgi_init__(self, *args, **kwargs) -> None: thread_name_prefix="rsgi", ) self.state.ready_at = time.time() - return None - async def __rsgi_del__(self, *args, **kwargs) -> None: + async def on_shutdown(self) -> None: pool = self.state.thread_pool if pool is not None: pool.shutdown(wait=True) @@ -57,7 +61,6 @@ async def __rsgi_del__(self, *args, **kwargs) -> None: self.state.thread_pool = None if hasattr(self.state, "bg_limit"): del self.state.bg_limit - return None app = LifespanApp(title="Lifespan example") diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index cdd0111..4abb2ce 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -5,10 +5,13 @@ from oxyroute.cors import CORSConfig, apply_cors from oxyroute.csrf import CSRFConfig, apply_csrf, csrf_layer from oxyroute.exceptions import HTTPException +from oxyroute.request import Request from oxyroute.response import Response from oxyroute.router import APIRouter from oxyroute.security_headers import SecurityHeadersConfig from oxyroute.sse import SSEEvent, send_sse, sse_done +from oxyroute.static import StaticFiles +from oxyroute.streaming import stream_bytes, stream_jsonl, stream_text __all__ = [ "APIRouter", @@ -18,9 +21,11 @@ "DBQuery", "Depends", "HTTPException", + "Request", "Response", "SSEEvent", "SecurityHeadersConfig", + "StaticFiles", "WebSocket", "__version__", "apply_cors", @@ -29,5 +34,8 @@ "decode_jwt_hs", "send_sse", "sse_done", + "stream_bytes", + "stream_jsonl", + "stream_text", ] -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/oxyroute/app.py b/oxyroute/app.py index a67f5d4..0a72ce7 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -7,6 +7,8 @@ from typing import Any, TypeVar from . import _oxyroute +from .docs_ui import docs_html, normalize_docs_ui +from .response import Response from .router import APIRouter, join_path F = TypeVar("F", bound=Callable[..., Any]) @@ -19,6 +21,38 @@ def _unwrap_dep(f: Dep) -> Any: return f +class _ProtocolWrapper: + __slots__ = ("__oxyroute_path_template__", "_inner", "status") + + def __init__(self, inner: Any) -> None: + self._inner = inner + self.status: int = 500 + self.__oxyroute_path_template__: str = "" + + def __oxyroute_set_path_template__(self, template: str) -> None: + self.__oxyroute_path_template__ = template + + def response_empty(self, status: int, headers: list[tuple[str, str]]) -> None: + self.status = status + self._inner.response_empty(status, headers) + + def response_str(self, status: int, headers: list[tuple[str, str]], body: str) -> None: + self.status = status + self._inner.response_str(status, headers, body) + + def response_bytes(self, status: int, headers: list[tuple[str, str]], body: bytes) -> None: + self.status = status + self._inner.response_bytes(status, headers, body) + + def response_file(self, status: int, headers: list[tuple[str, str]], file_path: str) -> None: + self.status = status + self._inner.response_file(status, headers, file_path) + + def response_stream(self, status: int, headers: list[tuple[str, str]]) -> Any: + self.status = status + return self._inner.response_stream(status, headers) + + def _norm_dependencies( deps: list[tuple[str, Dep]] | None, ) -> list[tuple[str, Any]] | None: @@ -39,16 +73,41 @@ class App: the legacy ASGI bridge was removed in v0.3.0. ``state`` is an empty ``types.SimpleNamespace`` for per-process data; set fields in - ``__rsgi_init__`` or a factory, or on a subclass. In-memory data is not shared across - Granian worker processes. + ``on_startup`` / ``__rsgi_init__`` or a factory, or on a subclass. In-memory data is + not shared across Granian worker processes. """ - def __init__(self, title: str = "OxyRoute", *, include_openapi: bool = True) -> None: + def __init__( + self, + title: str = "OxyRoute", + *, + include_openapi: bool = True, + docs_ui: str | None = None, + openapi_description: str | None = None, + openapi_contact: Mapping[str, Any] | None = None, + openapi_servers: list[Mapping[str, Any]] | None = None, + access_log_hook: Callable[[Any, int, float, str], None] | None = None, + ) -> None: self._app = _oxyroute.App(include_openapi=include_openapi) self._app.set_openapi_title(title) self.title = title - # Per-process mutable bag for ``__rsgi_init__`` / factory setup (DB pool, clients, …). + self.access_log_hook = access_log_hook + # Per-process mutable bag for ``on_startup`` / factory setup (DB pool, clients, …). self.state: SimpleNamespace = SimpleNamespace() + self._docs_ui: str | None = normalize_docs_ui(docs_ui) + self._docs_mounted: bool = False + if ( + openapi_description is not None + or openapi_contact is not None + or openapi_servers is not None + ): + self.set_openapi_info( + description=openapi_description, + contact=openapi_contact, + servers=openapi_servers, + ) + if self._docs_ui is not None: + self.mount_docs("/docs", ui=self._docs_ui) def freeze(self) -> None: """After ``freeze()``, no more route registration (matches Rust app state).""" @@ -58,17 +117,61 @@ def set_openapi_served(self, enabled: bool) -> None: """Enable or disable the built-in ``GET /openapi.json`` route.""" self._app.set_openapi_served(enabled) + def set_openapi_info( + self, + *, + description: str | None = None, + contact: Mapping[str, Any] | None = None, + servers: list[Mapping[str, Any]] | None = None, + ) -> None: + """ + Enrich the OpenAPI document ``info`` and optional ``servers`` list. + + ``contact`` is an OpenAPI contact object (e.g. ``{"name": "…", "email": "…"}``). + ``servers`` is a list of ``{"url": "…", "description": "…"}`` objects. + """ + contact_json = json.dumps(dict(contact)) if contact is not None else None + servers_json = json.dumps([dict(s) for s in servers]) if servers is not None else None + self._app.set_openapi_info(description, contact_json, servers_json) + + def mount_docs( + self, + path: str = "/docs", + *, + ui: str = "scalar", + openapi_url: str = "/openapi.json", + ) -> None: + """ + Register ``GET path`` serving Scalar or Swagger UI against ``openapi_url``. + + UI assets load from a public CDN; set CSP ``script-src`` / ``style-src`` accordingly + (or disable security-header presets that block CDN scripts on the docs route). + """ + ui_n = normalize_docs_ui(ui) + if ui_n is None: + raise ValueError("ui is required") + path = path.rstrip("/") or "/docs" + html = docs_html(ui=ui_n, title=self.title, openapi_url=openapi_url) + headers = {"content-type": "text/html; charset=utf-8"} + + def _docs() -> Response: + return Response(body=html, status=200, headers=headers) + + self.get(path)(_docs) + self._docs_ui = ui_n + self._docs_mounted = True + async def setup_database(self, url: str, max_connections: int = 10) -> None: """ Connect to a PostgreSQL database and store the pool in the Rust hot path. - Must be awaited (e.g. inside ``__rsgi_init__``). + Must be awaited (e.g. inside ``on_startup``). """ await self._app.setup_database(url, max_connections) async def close_database(self) -> None: """ Close the global PostgreSQL connection pool. - Must be awaited (e.g. inside ``__rsgi_del__``). + Must be awaited (e.g. inside ``on_shutdown``). """ await self._app.close_database() @@ -98,6 +201,14 @@ def include_router( kw: dict[str, Any] = {k: v for k, v in merged.items() if k in allowed} reg(self, full, **kw)(handler) + def add_exception_handler( + self, exc_type: type[BaseException], handler: Callable[..., Any] + ) -> None: + """ + Register a global exception handler for a specific exception type. + """ + self._app.add_exception_handler(exc_type, handler) + def set_middleware(self, handler: Callable[..., Any] | None) -> None: """ One optional pre-route callback ``(scope, protocol)`` — return ``None`` to pass through. @@ -124,6 +235,15 @@ def set_security_headers(self, config: Any | None) -> None: """ self._app.set_security_headers(config) + def mount(self, path: str, app: Any) -> None: + """Mount another application or handler at a specific path prefix.""" + path = path.rstrip("/") + # Mount the exact prefix + self.get(path)(app) + self.get(path + "/")(app) + # Mount all subpaths + self.get(path + "/*path")(app) + def get( self, path: str, @@ -136,6 +256,7 @@ def get( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "GET", @@ -150,6 +271,7 @@ def get( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def post( @@ -168,6 +290,7 @@ def post( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "POST", @@ -184,6 +307,7 @@ def post( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def put( @@ -202,6 +326,7 @@ def put( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "PUT", @@ -218,6 +343,7 @@ def put( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def patch( @@ -236,6 +362,7 @@ def patch( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "PATCH", @@ -252,6 +379,7 @@ def patch( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def delete( @@ -266,6 +394,7 @@ def delete( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "DELETE", @@ -280,6 +409,7 @@ def delete( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def websocket(self, path: str) -> Callable[[F], F]: @@ -312,6 +442,7 @@ def options( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "OPTIONS", @@ -326,6 +457,7 @@ def options( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def _route( @@ -345,6 +477,7 @@ def _route( jwt_cookie: str | None = None, body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: dlist = _norm_dependencies(dependencies) @@ -374,27 +507,72 @@ def wrap(handler: F) -> F: jwt_leeway, jwt_cookie, body_schema_json, + body_model, + tags, ) return handler return wrap - async def __rsgi_init__(self, *args: Any, **kwargs: Any) -> None: + @staticmethod + def _run_lifespan(coro: Any, loop: Any | None) -> Any: + """ + Granian calls sync ``__rsgi_init__(loop)`` / ``__rsgi_del__(loop)`` with a + **non-running** event loop — use ``run_until_complete``. TestClient and + ``await app.__rsgi_init__()`` pass no loop and receive the coroutine. + """ + if loop is not None and hasattr(loop, "run_until_complete"): + try: + running = bool(loop.is_running()) + except Exception: + running = False + if not running: + return loop.run_until_complete(coro) + return coro + + async def on_startup(self) -> None: """ - RSGI worker startup (no-op in the base class). Subclass to open pools/clients; see - ``docs/rsgi.md`` (Lifespan) and ``examples/rsgi_lifespan_app.py``. + Per-worker async startup. Override in a subclass; the base class runs this from + sync :meth:`__rsgi_init__` under Granian. """ return None - async def __rsgi_del__(self, *args: Any, **kwargs: Any) -> None: - """RSGI worker teardown. Closes the global connection pool if it exists.""" + async def on_shutdown(self) -> None: + """Per-worker async teardown. Closes the global connection pool if it exists.""" await self.close_database() + def __rsgi_init__(self, loop: Any | None = None, *args: Any, **kwargs: Any) -> Any: + """ + RSGI worker startup (Granian-compatible). + + Granian invokes this as a **sync** method with the worker ``loop`` (not running) + and expects ``loop.run_until_complete(...)``. Prefer overriding :meth:`on_startup` + instead of this method. When called with no ``loop`` (tests), returns the + ``on_startup`` coroutine for the caller to await. + """ + return self._run_lifespan(self.on_startup(), loop) + + def __rsgi_del__(self, loop: Any | None = None, *args: Any, **kwargs: Any) -> Any: + """RSGI worker teardown; see :meth:`__rsgi_init__` / :meth:`on_shutdown`.""" + return self._run_lifespan(self.on_shutdown(), loop) + async def __rsgi__(self, scope: Any, protocol: Any) -> Any: """ Granian awaits this coroutine. Native ``handle_rsgi`` may return ``None`` immediately (sync short-circuit for openapi / 404 / 405) or an awaitable (full async path). """ + if self.access_log_hook: + import time + + start = time.perf_counter_ns() + p = _ProtocolWrapper(protocol) + r = self._app.handle_rsgi(scope, p) + if r is not None and inspect.isawaitable(r): + await r + dur = (time.perf_counter_ns() - start) / 1000000.0 + self.access_log_hook(scope, p.status, dur, p.__oxyroute_path_template__) + return r + r = self._app.handle_rsgi(scope, protocol) if r is None or not inspect.isawaitable(r): return r diff --git a/oxyroute/cors.py b/oxyroute/cors.py index a77d200..c119cea 100644 --- a/oxyroute/cors.py +++ b/oxyroute/cors.py @@ -22,7 +22,8 @@ class CORSConfig: response header merging without the built-in ``OPTIONS`` handler. ``response_header_pairs`` is called from Rust to merge CORS headers into normal responses - (after a route or middleware that returns a body). + (after a route or middleware that returns a body). The native layer skips this call when + the request has no ``Origin`` header (same outcome as an empty pair list). """ allow_origins: list[str] = field(default_factory=lambda: ["*"]) diff --git a/oxyroute/docs_ui.py b/oxyroute/docs_ui.py new file mode 100644 index 0000000..949344d --- /dev/null +++ b/oxyroute/docs_ui.py @@ -0,0 +1,81 @@ +"""Built-in OpenAPI docs UIs (Scalar / Swagger UI) loaded from CDN.""" + +from __future__ import annotations + +from html import escape + +__all__ = ["docs_html", "normalize_docs_ui"] + +_VALID = frozenset({"scalar", "swagger"}) + + +def normalize_docs_ui(ui: str | None) -> str | None: + if ui is None: + return None + v = ui.strip().lower() + if v not in _VALID: + raise ValueError(f"docs_ui must be one of {sorted(_VALID)} or None, got {ui!r}") + return v + + +def docs_html( + *, + ui: str, + title: str, + openapi_url: str = "/openapi.json", +) -> str: + """Return HTML for Scalar or Swagger UI pointing at ``openapi_url``.""" + ui_n = normalize_docs_ui(ui) + if ui_n is None: + raise ValueError("docs_ui is required") + safe_title = escape(title) + safe_url = escape(openapi_url, quote=True) + if ui_n == "scalar": + return _scalar_html(safe_title, safe_url) + return _swagger_html(safe_title, safe_url) + + +def _scalar_html(title: str, openapi_url: str) -> str: + return f""" + + + + + {title} — API docs + + + + + + +""" + + +def _swagger_html(title: str, openapi_url: str) -> str: + return f""" + + + + + {title} — API docs + + + + +
+ + + + +""" diff --git a/oxyroute/request.py b/oxyroute/request.py new file mode 100644 index 0000000..bfb79cc --- /dev/null +++ b/oxyroute/request.py @@ -0,0 +1,72 @@ +from collections.abc import Iterator, Mapping +from typing import Any + + +class Request(Mapping[str, Any]): + """ + A typed Request context object providing lazy access to headers and other scope properties. + Preserves dictionary-like access (`request["headers"]`) for backwards compatibility. + """ + + def __init__(self, scope: Any, method: str, path: str, query_string: str) -> None: + self.scope = scope + self.method = method + self.path = path + self.query_string = query_string + self._headers: dict[str, str] | None = None + + @property + def headers(self) -> dict[str, str]: + if self._headers is None: + if isinstance(self.scope, dict): + h = self.scope.get("headers", []) + if isinstance(h, dict): + self._headers = {str(k): str(v) for k, v in h.items()} + else: + self._headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in h} + else: + h = getattr(self.scope, "headers", {}) + if hasattr(h, "_d"): + h = h._d + self._headers = dict(h) + return self._headers + + @property + def client(self) -> str | None: + if isinstance(self.scope, dict): + client = self.scope.get("client") + if client: + return f"{client[0]}:{client[1]}" + else: + return getattr(self.scope, "client", None) + return None + + @property + def cookies(self) -> dict[str, str]: + # Minimal cookie parsing from headers + cookie_header = self.headers.get("cookie") + if not cookie_header: + return {} + cookies = {} + for chunk in cookie_header.split(";"): + if "=" in chunk: + k, v = chunk.split("=", 1) + cookies[k.strip()] = v.strip() + return cookies + + def __getitem__(self, key: str) -> Any: + if key == "headers": + return self.headers + if key == "method": + return self.method + if key == "path": + return self.path + if key == "query_string": + return self.query_string + raise KeyError(key) + + def __iter__(self) -> Iterator[str]: + return iter(["method", "path", "query_string", "headers"]) + + def __len__(self) -> int: + return 4 diff --git a/oxyroute/router.py b/oxyroute/router.py index 3e10488..64ee6ed 100644 --- a/oxyroute/router.py +++ b/oxyroute/router.py @@ -50,7 +50,9 @@ def __init__(self) -> None: def _reg(self, method: str, path: str, **opts: Any) -> Callable[[F], F]: def dec(handler: F) -> F: - self._routes.append((method, path, handler, dict(opts))) + # Drop ``None`` so ``include_router(..., tags=[...])`` defaults are not wiped. + cleaned = {k: v for k, v in opts.items() if v is not None} + self._routes.append((method, path, handler, cleaned)) return handler return dec @@ -67,6 +69,15 @@ def include_router( full = join_path(prefix, rel) self._routes.append((method, full, handler, merged)) + def mount(self, path: str, app: Any) -> None: + """Mount another application or handler at a specific path prefix.""" + path = path.rstrip("/") + # Mount the exact prefix + self.get(path)(app) + self.get(path + "/")(app) + # Mount all subpaths + self.get(path + "/*path")(app) + def get( self, path: str, @@ -79,6 +90,7 @@ def get( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "GET", @@ -91,6 +103,7 @@ def get( jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, dependencies=dependencies, + tags=tags, ) def post( @@ -109,6 +122,7 @@ def post( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "POST", @@ -125,6 +139,7 @@ def post( body_model=body_model, body_schema=body_schema, dependencies=dependencies, + tags=tags, ) def put( @@ -143,6 +158,7 @@ def put( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "PUT", @@ -159,6 +175,7 @@ def put( body_model=body_model, body_schema=body_schema, dependencies=dependencies, + tags=tags, ) def patch( @@ -177,6 +194,7 @@ def patch( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "PATCH", @@ -193,6 +211,7 @@ def patch( body_model=body_model, body_schema=body_schema, dependencies=dependencies, + tags=tags, ) def delete( @@ -207,6 +226,7 @@ def delete( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "DELETE", @@ -219,6 +239,7 @@ def delete( jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, dependencies=dependencies, + tags=tags, ) def options( @@ -233,6 +254,7 @@ def options( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "OPTIONS", @@ -245,4 +267,5 @@ def options( jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, dependencies=dependencies, + tags=tags, ) diff --git a/oxyroute/static.py b/oxyroute/static.py new file mode 100644 index 0000000..ecb7697 --- /dev/null +++ b/oxyroute/static.py @@ -0,0 +1,66 @@ +import mimetypes +import os +from typing import Any + +from oxyroute.exceptions import HTTPException + + +class StaticFiles: + """ + Serve static files from a directory. + + Can be mounted via: + app.mount("/static", StaticFiles("static", html=True)) + """ + + __name__ = "StaticFiles" + + def __init__( + self, + directory: str, + html: bool = False, + max_age: int | None = None, + ) -> None: + self.directory = os.path.abspath(directory) + if not os.path.isdir(self.directory): + raise RuntimeError(f"Directory {directory} does not exist") + self.html = html + self.max_age = max_age + + def __call__(self, protocol: Any, path: str = "") -> Any: + if ".." in path.split("/"): + raise HTTPException(status_code=403, detail="Forbidden") + + file_path = os.path.abspath(os.path.join(self.directory, path.lstrip("/"))) + + if not file_path.startswith(self.directory): + raise HTTPException(status_code=403, detail="Forbidden") + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + if self.html and os.path.isfile(os.path.join(file_path, "index.html")): + file_path = os.path.join(file_path, "index.html") + else: + raise HTTPException(status_code=404, detail="Not Found") + + content_type, _ = mimetypes.guess_type(file_path) + if content_type is None: + content_type = "application/octet-stream" + + headers = [("content-type", content_type)] + if self.max_age is not None: + headers.append(("cache-control", f"public, max-age={self.max_age}")) + + if hasattr(protocol, "response_file"): + # Rust fast path via tokio-fs + protocol.response_file(200, headers, file_path) + from oxyroute.streaming import stream_done + + return stream_done() + else: + # Fallback for Python testing transports without response_file + with open(file_path, "rb") as f: + body = f.read() + protocol.response_bytes(200, headers, body) + from oxyroute.streaming import stream_done + + return stream_done() diff --git a/oxyroute/streaming.py b/oxyroute/streaming.py new file mode 100644 index 0000000..67ef632 --- /dev/null +++ b/oxyroute/streaming.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterable, Iterable +from typing import Any + +__all__ = ["stream_bytes", "stream_done", "stream_jsonl", "stream_text"] + + +class _StreamDone: + __slots__ = ("status",) + __oxyroute_stream_done__ = True + + def __init__(self, status: int = 200) -> None: + self.status = status + + +def stream_done(status: int = 200) -> Any: + """Return a marker value telling OxyRoute the response was already sent.""" + return _StreamDone(status) + + +async def stream_bytes( + protocol: Any, + iterable: Iterable[bytes] | AsyncIterable[bytes], + *, + status: int = 200, + headers: list[tuple[str, str]] | None = None, + content_type: str = "application/octet-stream", +) -> Any: + """ + Stream raw bytes via RSGI protocol. + + If `response_stream` is available (Granian RSGI), writes chunks incrementally. + Otherwise (ASGI bridge/test transports), falls back to a single response body. + """ + base_headers: list[tuple[str, str]] = [("content-type", content_type)] + if headers: + base_headers.extend(headers) + + stream_factory = getattr(protocol, "response_stream", None) + if callable(stream_factory): + stream = stream_factory(status, base_headers) + if hasattr(iterable, "__aiter__"): + async for chunk in iterable: # type: ignore[union-attr] + await stream.send_bytes(chunk) + else: + for chunk in iterable: # type: ignore[not-an-iterable] + await stream.send_bytes(chunk) + return stream_done(status) + + # Fallback for test/ASGI transports + chunks: list[bytes] = [] + if hasattr(iterable, "__aiter__"): + async for chunk in iterable: # type: ignore[union-attr] + chunks.append(chunk) + else: + for chunk in iterable: # type: ignore[not-an-iterable] + chunks.append(chunk) + protocol.response_bytes(status, base_headers, b"".join(chunks)) + return stream_done(status) + + +async def stream_text( + protocol: Any, + iterable: Iterable[str] | AsyncIterable[str], + *, + status: int = 200, + headers: list[tuple[str, str]] | None = None, + content_type: str = "text/plain; charset=utf-8", +) -> Any: + """ + Stream text via RSGI protocol. + + If `response_stream` is available (Granian RSGI), writes chunks incrementally. + Otherwise (ASGI bridge/test transports), falls back to a single response body. + """ + base_headers: list[tuple[str, str]] = [("content-type", content_type)] + if headers: + base_headers.extend(headers) + + stream_factory = getattr(protocol, "response_stream", None) + if callable(stream_factory): + stream = stream_factory(status, base_headers) + if hasattr(iterable, "__aiter__"): + async for chunk in iterable: # type: ignore[union-attr] + await stream.send_str(chunk) + else: + for chunk in iterable: # type: ignore[not-an-iterable] + await stream.send_str(chunk) + return stream_done(status) + + chunks: list[str] = [] + if hasattr(iterable, "__aiter__"): + async for chunk in iterable: # type: ignore[union-attr] + chunks.append(chunk) + else: + for chunk in iterable: # type: ignore[not-an-iterable] + chunks.append(chunk) + protocol.response_str(status, base_headers, "".join(chunks)) + return stream_done(status) + + +async def stream_jsonl( + protocol: Any, + iterable: Iterable[Any] | AsyncIterable[Any], + *, + status: int = 200, + headers: list[tuple[str, str]] | None = None, +) -> Any: + """ + Stream NDJSON (JSON-Lines) via RSGI protocol. + """ + + async def _jsonl_iter() -> AsyncIterable[str]: + if hasattr(iterable, "__aiter__"): + async for item in iterable: # type: ignore[union-attr] + yield json.dumps(item) + "\n" + else: + for item in iterable: # type: ignore[not-an-iterable] + yield json.dumps(item) + "\n" + + return await stream_text( + protocol, + _jsonl_iter(), + status=status, + headers=headers, + content_type="application/x-ndjson; charset=utf-8", + ) diff --git a/tests/_rsgi_test_transport.py b/oxyroute/testing.py similarity index 81% rename from tests/_rsgi_test_transport.py rename to oxyroute/testing.py index e708904..1ba44a5 100644 --- a/tests/_rsgi_test_transport.py +++ b/oxyroute/testing.py @@ -18,6 +18,8 @@ from collections.abc import Callable from typing import Any +import httpx + _BLOCKING_LOOP_LOCAL = threading.local() @@ -248,6 +250,16 @@ def response_bytes(self, status: int, headers: list, body: bytes) -> None: } ) + def response_file(self, status: int, headers: list, file: str) -> None: + import anyio + + async def _read_file() -> bytes: + async with await anyio.open_file(file, "rb") as f: + return await f.read() + + body = asyncio.run_coroutine_threadsafe(_read_file(), self._loop).result() + self.response_bytes(status, headers, body) + def response_empty(self, status: int, headers: list) -> None: self._status = int(status) self.status = int(status) @@ -342,6 +354,9 @@ async def asgi_to_rsgi( qs.decode("utf-8") if qs else "", hdrs, ) + client = scope.get("client") + if client: + rscope.client = f"{client[0]}:{client[1]}" loop = asyncio.get_running_loop() queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() @@ -406,3 +421,62 @@ async def asgi3(scope: dict[str, Any], receive: Any, send: Any) -> None: asgi_test_app = build_test_app """Alias: ``asgi_test_app(app)`` returns an ASGI3 callable for httpx.ASGITransport.""" + + +class TestClient(httpx.Client): + """Synchronous test client for OxyRoute apps. + + Wraps the RSGI testing transport and an async httpx client in a background + thread so it can be used in fully synchronous tests. + """ + + def __init__(self, app: Any, base_url: str = "http://testserver", **kwargs: Any) -> None: + self.app = app + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + transport = httpx.ASGITransport(app=asgi_test_app(app), client=("127.0.0.1", 12345)) + self.async_client = httpx.AsyncClient(transport=transport, base_url=base_url, **kwargs) + + super().__init__( + transport=httpx.MockTransport(lambda r: httpx.Response(200)), + base_url=base_url, + **kwargs, + ) + + def _run_loop(self) -> None: + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + def _run_sync(self, coro: Any) -> Any: + return asyncio.run_coroutine_threadsafe(coro, self._loop).result() + + def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: + resp = self._run_sync(self.async_client.send(request, **kwargs)) + self._run_sync(resp.aread()) + return resp + + def close(self) -> None: + self._run_sync(self.async_client.aclose()) + if self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join() + super().close() + + def __enter__(self) -> TestClient: + self._run_sync(self.async_client.__aenter__()) + if hasattr(self.app, "__rsgi_init__"): + init = self.app.__rsgi_init__() + if asyncio.iscoroutine(init): + self._run_sync(init) + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + self._run_sync(self.async_client.__aexit__(exc_type, exc_value, traceback)) + if hasattr(self.app, "__rsgi_del__"): + dele = self.app.__rsgi_del__() + if asyncio.iscoroutine(dele): + self._run_sync(dele) + self.close() + super().__exit__(exc_type, exc_value, traceback) diff --git a/perf-test/README.md b/perf-test/README.md index 304858d..1e8e805 100644 --- a/perf-test/README.md +++ b/perf-test/README.md @@ -1,77 +1,64 @@ # perf-test -Reproducible micro-bench harness for OxyRoute vs FastAPI. +Reproducible load and micro-bench harness for OxyRoute (issue #110). ## Apps -- `app.py` -> OxyRoute hello endpoint (`GET /`) -- `fastapi_app.py` -> FastAPI hello endpoint (`GET /`) - -Both return plain text `hello world` to keep payloads equivalent. +| File | Purpose | +|------|---------| +| `app_oxyroute.py` | Minimal hello `GET /` (RSGI) for `bench_hello.sh` | +| `app_fastapi.py` | FastAPI hello for compare | +| `app_scenarios.py` | Multi-route app for `bench_scenarios.sh` (text, JSON, JWT, CORS, Depends) | +| `app.py` / `fastapi_app.py` | Older compare harness used by `bench.sh` | ## Prerequisites -- `wrk` installed -- `granian` installed -- For FastAPI runs: `uv` (uses temporary dependency install via `--with fastapi`) - -## Default benchmark profile +- `wrk` +- `granian` +- Editable OxyRoute (`uv sync --extra dev --extra bench`) +- For FastAPI compare: FastAPI (bench extra) +- For JWT scenario token: PyJWT (bench extra) -- Server tuning: `--workers 2 --runtime-mode mt --runtime-threads 1` -- Load profile: `wrk -t4 -c128 -d15s` -- Repetitions: `3` +## Criterion microbenchmarks (Rust) -## Run (full compare) +Opt-in; **not** required in default CI. Measures hot-path primitives without wrk: -From repository root: +| Group | Cases | +|-------|--------| +| `match_route_compiled` | static `/hello`, param `/items/:id` | +| `map_handler_return` | Python `str` / `bytes` | +| `json_to_py` | small object, nested document | ```bash -bash perf-test/bench.sh +# From repo root (needs a Python interpreter for PyO3 link) +cargo bench --bench hot_path ``` -The script prints per-run metrics plus average/median RPS and relative delta. +HTML reports land under `target/criterion/`. **Before opening a perf PR**, run the same bench on `dev` and on your branch and paste key numbers (or attach the report) so reviewers can see deltas. ## Hello-world RPS (OxyRoute vs FastAPI) -Minimal comparison on `GET /` returning plain text, both served by -[Granian](https://github.com/emmett-framework/granian): - -- OxyRoute: `--interface rsgi` -- FastAPI: `--interface asgi` +Minimal comparison on `GET /` returning plain text, both served by Granian: -## Setup +- OxyRoute: `--interface rsgi` (`app_oxyroute.py`) +- FastAPI: `--interface asgi` (`app_fastapi.py`) -Run these from the **repository root** (the directory that contains `pyproject.toml`). If you `cd perf-test` first, editable installs and `uv sync` must still be run from the parent, or use `uv pip install -e "..[bench]"`. +### Setup ```bash cd /path/to/OxyRoute -# Include both `dev` and `bench` — `uv sync --extra bench` alone drops the `dev` group (pytest, ruff, …). uv sync --extra dev --extra bench -# or: uv sync --all-extras -# or: uv pip install -e ".[bench]" # wrk: sudo apt install wrk / brew install wrk ``` -`bench_hello.sh` uses `REPO/.venv/bin/python` when present so it does not fall back to **system** `python3` (where FastAPI is usually missing). Override with `PYTHON=/path/to/python` if needed. +`bench_hello.sh` prefers `REPO/.venv/bin/python` when present. -## Run (`bench_hello.sh`) - -From the repository root: +### Run ```bash ./perf-test/bench_hello.sh ``` -From inside `perf-test/` (same effect): - -```bash -bash bench_hello.sh -``` - -(Ensure the venv has `oxyroute` and `fastapi`— simplest is to stay at repo root and use the paths above.) - -Optional environment knobs for `bench_hello.sh`: - | Variable | Default | Meaning | |----------|---------|---------| | `OXYROUTE_BENCH_DURATION` | `5s` | `wrk -d` | @@ -79,12 +66,41 @@ Optional environment knobs for `bench_hello.sh`: | `OXYROUTE_BENCH_CONNECTIONS` | `32` | `wrk -c` | | `OXYROUTE_BENCH_WORKERS` | `1` | Granian `--workers` | -## Optional pytest (short run) +## Scenario suite (`bench_scenarios.sh`) -With `wrk` and `fastapi` available: +Hits routes on `app_scenarios.py`: + +| Scenario | Path | Notes | +|----------|------|--------| +| `text` | `GET /` | Plain text | +| `json` | `POST /json` | JSON body + JSON response | +| `jwt` | `GET /jwt` | Bearer HS256 | +| `cors` | `GET /` | `Origin` header (CORS enabled on app) | +| `dep` | `GET /dep` | One `Depends` factory | + +```bash +./perf-test/bench_scenarios.sh +# or one scenario: +OXYROUTE_BENCH_SCENARIO=json ./perf-test/bench_scenarios.sh +``` + +Same `OXYROUTE_BENCH_*` knobs as hello, plus `OXYROUTE_BENCH_SCENARIO` (`all` \| `text` \| `json` \| `jwt` \| `cors` \| `dep`). + +## Optional pytest (short hello run) ```bash OXYROUTE_BENCH=1 uv run pytest tests/test_perf_hello_bench.py -m bench -v ``` -By default the bench test is skipped (no load on normal `pytest`). +Skipped unless `OXYROUTE_BENCH=1` (not for default CI). + +## Full compare (`bench.sh`) + +Older multi-rep harness — see script header. Default profile uses higher connection counts than the hello script. + +## Baseline checklist (perf PRs) + +1. `git checkout dev && cargo bench --bench hot_path` (save summary) +2. Your branch: same command +3. Optionally `./perf-test/bench_scenarios.sh` with fixed `OXYROUTE_BENCH_DURATION` / connections +4. Paste before/after numbers in the PR diff --git a/perf-test/app_scenarios.py b/perf-test/app_scenarios.py new file mode 100644 index 0000000..3f846d8 --- /dev/null +++ b/perf-test/app_scenarios.py @@ -0,0 +1,35 @@ +"""Multi-scenario OxyRoute app for ``bench_scenarios.sh`` (issue #110).""" + +from __future__ import annotations + +from oxyroute import App, Depends +from oxyroute.cors import CORSConfig, apply_cors + +SECRET = "bench-secret-key-do-not-use-in-prod" + +app = App(title="perf scenarios", include_openapi=False) +apply_cors(app, CORSConfig(allow_origins=["*"], allow_credentials=False)) + + +@app.get("/") +def plain_text() -> str: + return "hello" + + +@app.post("/json") +def json_echo(json: dict) -> dict: + return {"ok": True, "echo": json} + + +@app.get("/jwt", require_jwt=True, jwt_secret=SECRET, algorithms=["HS256"]) +def jwt_ok(claims: dict) -> str: + return f"sub={claims.get('sub', '')}" + + +def _dep_value() -> int: + return 42 + + +@app.get("/dep", dependencies=[("n", Depends(_dep_value))]) +def with_dep(n: int) -> str: + return f"n={n}" diff --git a/perf-test/bench_scenarios.sh b/perf-test/bench_scenarios.sh new file mode 100755 index 0000000..9145c6c --- /dev/null +++ b/perf-test/bench_scenarios.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Run wrk against OxyRoute scenario routes (issue #110). +# Requirements: granian, wrk, editable oxyroute (and PyJWT for the JWT scenario token). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -n "${PYTHON:-}" ]]; then + : +elif [[ -x "${ROOT}/.venv/bin/python" ]]; then + PYTHON="${ROOT}/.venv/bin/python" +else + PYTHON="python3" +fi + +DURATION="${OXYROUTE_BENCH_DURATION:-5s}" +THREADS="${OXYROUTE_BENCH_THREADS:-2}" +CONN="${OXYROUTE_BENCH_CONNECTIONS:-32}" +WORKERS="${OXYROUTE_BENCH_WORKERS:-1}" +SCENARIO="${OXYROUTE_BENCH_SCENARIO:-all}" + +if ! command -v wrk >/dev/null 2>&1; then + echo "error: wrk not found (install wrk and retry)" >&2 + exit 1 +fi + +_free_port() { + "${PYTHON}" -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1]); s.close()" +} + +_wait_http() { + local port="$1" + local deadline=$((SECONDS + 30)) + while (( SECONDS < deadline )); do + if "${PYTHON}" -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:${port}/', timeout=0.5).read()" 2>/dev/null; then + return 0 + fi + sleep 0.05 + done + return 1 +} + +_jwt_token() { + "${PYTHON}" - <<'PY' +import time +try: + import jwt +except ImportError as e: + raise SystemExit("PyJWT required for JWT scenario: uv sync --extra bench") from e +print(jwt.encode( + {"sub": "bench", "exp": int(time.time()) + 3600}, + "bench-secret-key-do-not-use-in-prod", + algorithm="HS256", +)) +PY +} + +_run_wrk() { + local url="$1" + shift + wrk -t"${THREADS}" -c"${CONN}" -d"${DURATION}" "$@" "${url}" 2>&1 \ + | awk '/Requests\/sec:/{gsub(/^[ \t]+/,"",$2); print $2; exit}' +} + +_start_server() { + local port="$1" + ( + export PYTHONPATH="${ROOT}" + cd "${ROOT}/perf-test" + exec "${PYTHON}" -m granian "app_scenarios:app" \ + --host 127.0.0.1 --port "${port}" --interface rsgi --workers "${WORKERS}" + ) >/dev/null 2>&1 & + echo $! +} + +_bench_one() { + local name="$1" path="$2" + shift 2 + local port pid rps + port="$(_free_port)" + pid="$(_start_server "${port}")" + if ! _wait_http "${port}"; then + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + echo "error: server did not become ready (${name})" >&2 + exit 1 + fi + rps=$(_run_wrk "http://127.0.0.1:${port}${path}" "$@") + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + printf '%-12s %s\n' "${name}" "${rps}" +} + +_lua_json() { + cat >"$1" <<'LUA' +wrk.method = "POST" +wrk.body = '{"a":1,"b":"x"}' +wrk.headers["Content-Type"] = "application/json" +LUA +} + +_lua_jwt() { + local token="$2" + cat >"$1" <"$1" <<'LUA' +wrk.headers["Origin"] = "https://bench.example" +LUA +} + +main() { + echo "bench_scenarios: OxyRoute RSGI" + echo " duration=${DURATION} threads=${THREADS} connections=${CONN} workers=${WORKERS} scenario=${SCENARIO}" + echo "" + + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "${tmp}"' EXIT + + local run_all=0 + [[ "${SCENARIO}" == "all" ]] && run_all=1 + + if (( run_all )) || [[ "${SCENARIO}" == "text" ]]; then + _bench_one "text_get" "/" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "json" ]]; then + _lua_json "${tmp}/json.lua" + _bench_one "json_post" "/json" -s "${tmp}/json.lua" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "jwt" ]]; then + local token + token="$(_jwt_token)" + _lua_jwt "${tmp}/jwt.lua" "${token}" + _bench_one "jwt_get" "/jwt" -s "${tmp}/jwt.lua" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "cors" ]]; then + _lua_cors "${tmp}/cors.lua" + _bench_one "cors_get" "/" -s "${tmp}/cors.lua" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "dep" ]]; then + _bench_one "dep_get" "/dep" + fi +} + +main "$@" diff --git a/pyproject.toml b/pyproject.toml index 92c6a3d..6eabe0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "oxyroute" -version = "0.4.0" +version = "0.5.0" description = "RSGI-first web framework: routing, JSON, and JWT in Rust (PyO3), Python handlers" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.10" @@ -48,10 +48,12 @@ bench = [ "fastapi>=0.100", "granian>=1.0", "httpx>=0.27", + "pyjwt>=2.8", ] [tool.maturin] module-name = "oxyroute._oxyroute" +features = ["extension-module"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/dispatch.rs b/src/dispatch.rs index 4448c4a..550dda1 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use parking_lot::RwLock; +use jsonwebtoken::decode; use jsonwebtoken::errors::ErrorKind; -use jsonwebtoken::{decode, Validation}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList, PyString, PyTuple}; +use pyo3::IntoPyObjectExt; use serde_json::Value as JsonValue; use crate::config; @@ -18,7 +19,7 @@ use crate::state::{ match_route_compiled, match_ws_route_compiled, methods_matching_path_compiled, route_is_trivial_sync, AppState, CompiledRouters, HotSnapshot, RouteEntry, }; -use crate::token::{build_decoding_key, extract_bearer, extract_cookie_value}; +use crate::token::{extract_bearer, extract_cookie_value}; use crate::websocket::WebSocket; type HttpExceptionPayload = (u16, Vec, Vec<(String, String)>); @@ -164,13 +165,51 @@ fn send_python_error_sync( method: &str, path: &str, err: PyErr, + scope: Option<&pyo3::Bound<'_, PyAny>>, + state: Option<&std::sync::Arc>>, ) -> PyResult<()> { + if let (Some(sc), Some(st)) = (scope, state) { + let snap = st.read().hot_snapshot(); + for (exc_type, handler, is_async) in snap.exception_handlers.iter().rev() { + if let Ok(exc_obj) = err.clone_ref(py).into_bound_py_any(py) { + if exc_obj.is_instance(exc_type.bind(py)).unwrap_or(false) { + if *is_async { + log::error!( + "Async exception handler cannot be called in sync route fallback: {}", + method + ); + continue; + } + let exc_obj_any = exc_obj.clone().into_any(); + if let Ok(res) = handler.bind(py).call1((sc.clone(), exc_obj_any)) { + match map_handler_return(py, &res.clone().unbind()) { + Ok(mapped) => { + return send_handler_map_inline( + py, + protocol, + method == "HEAD", + mapped, + ) + } + Err(e) => { + log::error!( + "Exception handler returned invalid type or map failed: {:?}", + e + ); + } + } + } + } + } + } + } if try_http_exception_sync(py, protocol, &err)? { return Ok(()); } send_internal_error_sync(py, protocol, method, path, err) } +#[allow(clippy::too_many_arguments)] fn run_trivial_sync_route( py: Python<'_>, protocol: &Py, @@ -178,22 +217,29 @@ fn run_trivial_sync_route( path: &str, is_head: bool, entry: &RouteEntry, + scope: &pyo3::Bound<'_, PyAny>, + state: &std::sync::Arc>, ) -> PyResult<()> { + let _ = protocol.setattr( + py, + "__oxyroute_path_template__", + entry.path_template.clone(), + ); let handler = entry.handler.bind(py); let out = match handler.call0() { Ok(x) => x.unbind(), Err(e) => { - return send_python_error_sync(py, protocol, method, path, e); + return send_python_error_sync(py, protocol, method, path, e, Some(scope), Some(state)); } }; let mapped = match map_handler_return(py, &out) { Ok(m) => m, Err(e) => { - return send_python_error_sync(py, protocol, method, path, e); + return send_python_error_sync(py, protocol, method, path, e, Some(scope), Some(state)); } }; if let Err(e) = send_handler_map_inline(py, protocol, is_head, mapped) { - return send_python_error_sync(py, protocol, method, path, e); + return send_python_error_sync(py, protocol, method, path, e, Some(scope), Some(state)); } Ok(()) } @@ -204,7 +250,59 @@ async fn send_python_error( method: &str, path: &str, err: PyErr, + scope: Option<&Py>, + state: Option<&std::sync::Arc>>, ) -> PyResult { + if let (Some(sc), Some(st)) = (scope, state) { + let snap = st.read().hot_snapshot(); + let coro_or_res = Python::with_gil(|py| -> PyResult, bool)>> { + for (exc_type, handler, is_async) in snap.exception_handlers.iter().rev() { + if let Ok(exc_obj) = err.clone_ref(py).into_bound_py_any(py) { + if exc_obj.is_instance(exc_type.bind(py)).unwrap_or(false) { + let exc_obj_any = exc_obj.clone().into_any(); + if let Ok(res) = handler.bind(py).call1((sc.bind(py).clone(), exc_obj_any)) + { + return Ok(Some((res.unbind(), *is_async))); + } + } + } + } + Ok(None) + }); + + if let Ok(Some((res_py, is_async))) = coro_or_res { + let final_res = if is_async { + let fut = Python::with_gil(|py| { + pyo3_async_runtimes::tokio::into_future(res_py.bind(py).clone()) + }); + if let Ok(f) = fut { + match f.await { + Ok(x) => x, + Err(e) => return send_internal_error(protocol, method, path, e).await, + } + } else { + res_py + } + } else { + res_py + }; + + let mapped_res = Python::with_gil(|py| map_handler_return(py, &final_res)); + if let Ok(mapped) = mapped_res { + let res = Python::with_gil(|py| { + send_handler_map_inline(py, protocol, method == "HEAD", mapped) + }); + if res.is_ok() { + return Ok(Python::with_gil(|py| py.None())); + } + } else { + log::error!( + "Async exception handler returned invalid type or map failed: {:?}", + mapped_res.err() + ); + } + } + } if let Some(res) = try_http_exception(protocol, &err).await? { return Ok(res); } @@ -255,7 +353,15 @@ pub fn try_rsgi_sync_short_circuit( } if (method == "GET" || method == "HEAD") && path == "/openapi.json" && snapshot.include_openapi { - let doc = state.read().openapi.lock().to_string(); + let _ = protocol_py.setattr(py, "__oxyroute_path_template__", "/openapi.json"); + let doc: Arc = { + let state_guard = state.read(); + let mut oa = state_guard.openapi.lock(); + if oa.1.is_none() { + oa.1 = Some(Arc::new(oa.0.to_string())); + } + Arc::clone(oa.1.as_ref().unwrap()) + }; if is_head { response::send_head_simple_sync( py, @@ -298,7 +404,16 @@ pub fn try_rsgi_sync_short_circuit( return Err(pyo3::exceptions::PyRuntimeError::new_err("route index")); }; if route_is_trivial_sync(entry) { - run_trivial_sync_route(py, &protocol_py, &method, &path, is_head, entry)?; + run_trivial_sync_route( + py, + &protocol_py, + &method, + &path, + is_head, + entry, + scope, + state, + )?; return Ok(Some(py.None())); } Ok(None) @@ -373,7 +488,17 @@ pub async fn run_rsgi( }); if (method == "GET" || method == "HEAD") && path == "/openapi.json" && snapshot.include_openapi { - let doc = state.read().openapi.lock().to_string(); + let _ = Python::with_gil(|py| { + protocol.setattr(py, "__oxyroute_path_template__", "/openapi.json") + }); + let doc: Arc = { + let state_guard = state.read(); + let mut oa = state_guard.openapi.lock(); + if oa.1.is_none() { + oa.1 = Some(Arc::new(oa.0.to_string())); + } + Arc::clone(oa.1.as_ref().unwrap()) + }; if is_head { return response::send_head_simple( &protocol, @@ -387,6 +512,8 @@ pub async fn run_rsgi( } // Prototype: Issue 55 (sqlx integration benchmark path) if method == "GET" && path == "/test_db" { + let _ = + Python::with_gil(|py| protocol.setattr(py, "__oxyroute_path_template__", "/test_db")); if let Some(pool) = snapshot.db_pool.as_ref() { use sqlx::Row; match sqlx::query("SELECT 1 as num").fetch_one(pool).await { @@ -420,7 +547,8 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await; } }; let skip = Python::with_gil(|py| out.bind(py).is_none()); @@ -493,7 +621,10 @@ pub async fn run_rsgi( send_handler_map_inline(py, &protocol, is_head, mapped) }) { Ok(()) => Ok(Python::with_gil(|py| py.None())), - Err(e) => send_python_error(&protocol, &method, &path, e).await, + Err(e) => { + send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await + } }; } } @@ -529,12 +660,9 @@ pub async fn run_rsgi( handler, is_async, require_jwt, - jwt_secret, - algs, - jwt_issuer, - jwt_audience, - jwt_leeway, jwt_cookie, + jwt_decoding_key, + jwt_validation, read_json_body, read_form_body, dep_names, @@ -543,6 +671,7 @@ pub async fn run_rsgi( dep_wants_request, handler_param_names, handler_varkw, + body_model, ) = Python::with_gil(|_py| -> PyResult<_> { let e = routes_arc .get(route_idx) @@ -551,12 +680,9 @@ pub async fn run_rsgi( e.handler.clone(), e.is_async, e.require_jwt, - e.jwt_secret.clone(), - Arc::clone(&e.algs), - e.jwt_issuer.clone(), - e.jwt_audience.clone(), - e.jwt_leeway, e.jwt_cookie.clone(), + e.jwt_decoding_key.clone(), + e.jwt_validation.clone(), e.read_json_body, e.read_form_body, Arc::clone(&e.dep_names), @@ -565,10 +691,18 @@ pub async fn run_rsgi( Arc::clone(&e.dep_wants_request), Arc::clone(&e.handler_param_names), e.handler_varkw, + e.body_model.clone(), )) })?; let may_need_raw_body = handler_varkw || handler_param_names.contains("body"); let should_read_body = read_json_body || read_form_body || may_need_raw_body; + let _ = Python::with_gil(|py| { + protocol.setattr( + py, + "__oxyroute_path_template__", + routes_arc[route_idx].path_template.clone(), + ) + }); let mut body_bytes: Vec = if should_read_body { let read_fut = Python::with_gil(|py| { let p = protocol.bind(py); @@ -614,8 +748,9 @@ pub async fn run_rsgi( }; let mut claims_val: Option = None; if require_jwt { - let key = match jwt_secret { - None => { + let (dk, val) = match (jwt_decoding_key.as_ref(), jwt_validation.as_ref()) { + (Some(dk), Some(val)) => (dk, val), + _ => { return response::send_text( &protocol, 401, @@ -624,7 +759,6 @@ pub async fn run_rsgi( ) .await } - Some(s) => s, }; let token: String = match extract_bearer(auth.as_deref()).filter(|s| !s.is_empty()) { Some(t) => t, @@ -652,43 +786,7 @@ pub async fn run_rsgi( } }, }; - let mut val = if let Some(f) = algs.first() { - Validation::new(*f) - } else { - return response::send_text( - &protocol, - 401, - "Unauthorized", - "text/plain; charset=utf-8", - ) - .await; - }; - val.algorithms = algs.to_vec(); - val.validate_nbf = true; - val.leeway = jwt_leeway; - if let Some(ref iss) = jwt_issuer { - val.set_issuer(&[iss]); - } - if let Some(ref aud) = jwt_audience { - val.set_audience(&[aud]); - } else { - // jsonwebtoken 9: with validate_aud + aud=None, a token that includes `aud` fails - // (InvalidAudience). Disable unless the route opts in to an expected audience. - val.validate_aud = false; - } - let dk = match build_decoding_key(&key, &algs) { - Ok(d) => d, - Err(_) => { - return response::send_text( - &protocol, - 401, - "Unauthorized", - "text/plain; charset=utf-8", - ) - .await; - } - }; - match decode::(&token, &dk, &val) { + match decode::(&token, dk.as_ref(), val.as_ref()) { Ok(data) => { claims_val = Some(data.claims); } @@ -755,7 +853,15 @@ pub async fn run_rsgi( let ct = match ct { Ok(x) => x, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error( + &protocol, + &method, + &path, + e, + Some(&scope), + Some(&state), + ) + .await; } }; if ct.as_deref().map(str::is_empty) != Some(false) { @@ -816,11 +922,12 @@ pub async fn run_rsgi( match Python::with_gil(|py| -> PyResult> { let s = scope.bind(py); let d = build_request_context(py, s, &method, &path, &query_string)?; - Ok(d.unbind().into()) + Ok(d.unbind()) }) { Ok(o) => Some(o), Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await; } } } else { @@ -848,7 +955,15 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error( + &protocol, + &method, + &path, + e, + Some(&scope), + Some(&state), + ) + .await; } }; let fut = match Python::with_gil(|py| { @@ -857,13 +972,29 @@ pub async fn run_rsgi( }) { Ok(f) => f, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error( + &protocol, + &method, + &path, + e, + Some(&scope), + Some(&state), + ) + .await; } }; match fut.await { Ok(x) => x, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error( + &protocol, + &method, + &path, + e, + Some(&scope), + Some(&state), + ) + .await; } } } else { @@ -886,7 +1017,15 @@ pub async fn run_rsgi( }) { Ok(x) => x, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error( + &protocol, + &method, + &path, + e, + Some(&scope), + Some(&state), + ) + .await; } } }; @@ -905,7 +1044,17 @@ pub async fn run_rsgi( if let Some(pool) = snapshot.db_pool.as_ref() { match crate::db::execute_query(pool, &db_query).await { Ok(res) => res, - Err(e) => return send_python_error(&protocol, &method, &path, e).await, + Err(e) => { + return send_python_error( + &protocol, + &method, + &path, + e, + Some(&scope), + Some(&state), + ) + .await + } } } else { return send_python_error( @@ -915,12 +1064,17 @@ pub async fn run_rsgi( pyo3::exceptions::PyRuntimeError::new_err( "DBQuery returned by dependency but no database pool configured", ), + Some(&scope), + Some(&state), ) .await; } } Ok(None) => o, - Err(e) => return send_python_error(&protocol, &method, &path, e).await, + Err(e) => { + return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await + } }; dep_out.push(resolved); @@ -943,10 +1097,15 @@ pub async fn run_rsgi( || should_pass_files || should_pass_body || should_pass_protocol; - let (res, run_async) = match Python::with_gil(|py| -> PyResult<(PyObject, bool)> { + enum RunHandlerResult { + Ok((PyObject, bool)), + ValidationError(String), + } + + let (res, run_async) = match Python::with_gil(|py| -> PyResult { if !should_use_kwargs { let res = handler.bind(py).call0()?.unbind(); - return Ok((res, is_async)); + return Ok(RunHandlerResult::Ok((res, is_async))); } let kwargs = PyDict::new(py); for (k, v) in param_map { @@ -973,7 +1132,31 @@ pub async fn run_rsgi( } if let Some(ref j) = body_json { let pyv = json_to_py(py, j)?; - kwargs.set_item("json", pyv)?; + if let Some(ref bm) = body_model { + match bm.bind(py).call_method1("model_validate", (&pyv,)) { + Ok(validated) => { + kwargs.set_item("json", validated)?; + } + Err(e) => { + let err_str: String = + if let Ok(exc_obj) = e.clone_ref(py).into_bound_py_any(py) { + if let Ok(j_method) = exc_obj.call_method0("json") { + j_method + .extract::() + .unwrap_or_else(|_| "[]".to_string()) + } else { + "[]".to_string() + } + } else { + "[]".to_string() + }; + let err_json = format!(r#"{{"detail":{err_str}}}"#); + return Ok(RunHandlerResult::ValidationError(err_json)); + } + } + } else { + kwargs.set_item("json", pyv)?; + } } if read_form_body { if should_pass_form { @@ -1005,11 +1188,21 @@ pub async fn run_rsgi( kwargs.set_item("protocol", protocol.bind(py))?; } let res = handler.bind(py).call((), Some(&kwargs))?.unbind(); - Ok((res, is_async)) + Ok(RunHandlerResult::Ok((res, is_async))) }) { - Ok(x) => x, + Ok(RunHandlerResult::Ok((res, is_async))) => (res, is_async), + Ok(RunHandlerResult::ValidationError(err_json)) => { + return response::send_text( + &protocol, + 422, + &err_json, + "application/json; charset=utf-8", + ) + .await; + } Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await; } }; let handler_out: PyObject = if run_async { @@ -1019,13 +1212,15 @@ pub async fn run_rsgi( }) { Ok(f) => f, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await; } }; match fut.await { Ok(x) => x, Err(e) => { - return send_python_error(&protocol, &method, &path, e).await; + return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)) + .await; } } } else { @@ -1100,7 +1295,7 @@ pub async fn run_rsgi( send_handler_map_inline(py, &protocol, is_head, mapped) }) { Ok(()) => Ok(Python::with_gil(|py| py.None())), - Err(e) => send_python_error(&protocol, &method, &path, e).await, + Err(e) => send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state)).await, } } @@ -1194,6 +1389,18 @@ fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { }) } +/// Criterion helper (issue #110): run [`map_handler_return`] and return the mapped status. +#[doc(hidden)] +pub(crate) fn microbench_map_handler_return( + py: Python<'_>, + out: &Bound<'_, PyAny>, +) -> PyResult { + match map_handler_return(py, &out.clone().unbind())? { + HandlerMap::AlreadySent => Ok(0), + HandlerMap::WithHeaders { status, .. } | HandlerMap::Simple { status, .. } => Ok(status), + } +} + fn send_simple_body_sync( py: Python<'_>, protocol: &Py, @@ -1307,6 +1514,9 @@ fn send_handler_map_inline( /// `if_absent`: only add a header if no same-name (case-insensitive) header is already present /// (``security`` preset). `false` replaces/merges like CORS (``replace`` / duplicate header names). +/// +/// For CORS (`if_absent == false`), skip the Python `response_header_pairs` call when the +/// request has no ``Origin`` header (issue #108) — same outcome as an empty pair list. fn merge_config_response_headers( py: Python<'_>, config: &Option>, @@ -1317,6 +1527,12 @@ fn merge_config_response_headers( let Some(c) = config else { return Ok(mapped); }; + if !if_absent { + let headers = scope.getattr("headers")?; + if header_get_lax(&headers, "origin").is_none() { + return Ok(mapped); + } + } let pairs: Vec<(String, String)> = c .call_method1(py, "response_header_pairs", (&scope,))? .extract(py)?; diff --git a/src/lib.rs b/src/lib.rs index f94ae4c..4eb4549 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,37 @@ mod websocket; use dispatch::{run_rsgi, try_rsgi_sync_short_circuit}; use state::AppState; +/// Hidden Criterion / microbench surface (issue #110). Not part of the stable Python API. +#[doc(hidden)] +pub mod microbench { + use matchit::Router; + use pyo3::prelude::*; + + pub use crate::schema::json_to_py; + pub use crate::state::{match_route_compiled, CompiledRouters}; + + /// Build a compiled GET router with one static and one param route for matching benches. + pub fn sample_compiled_routers() -> CompiledRouters { + let mut get = Router::new(); + get.insert("/hello", 0usize).expect("static route"); + get.insert("/items/:id", 1usize).expect("param route"); + CompiledRouters { + get, + post: Router::new(), + put: Router::new(), + patch: Router::new(), + delete: Router::new(), + options: Router::new(), + websocket: Router::new(), + } + } + + /// Map a handler return value; returns HTTP status (0 if already sent). + pub fn map_handler_return_status(py: Python<'_>, out: &Bound<'_, PyAny>) -> PyResult { + crate::dispatch::microbench_map_handler_return(py, out) + } +} + type ParsedDependencies = (Vec, Vec>, Vec, Vec); /// Parameter names the route handler accepts, plus whether it has `**kwargs`. @@ -107,22 +138,83 @@ pub struct App { } impl App { + /// Convert matchit `:name` / `*rest` templates to OpenAPI `{name}` / `{rest}` and + /// collect path parameter objects. + fn openapi_path_and_params(path: &str) -> (String, Vec) { + let mut out = String::with_capacity(path.len() + 8); + let mut params = Vec::new(); + let chars: Vec = path.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == ':' || c == '*' { + i += 1; + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let name: String = chars[start..i].iter().collect(); + if !name.is_empty() { + out.push('{'); + out.push_str(&name); + out.push('}'); + params.push(json!({ + "name": name, + "in": "path", + "required": true, + "schema": { "type": "string" } + })); + } + } else { + out.push(c); + i += 1; + } + } + (out, params) + } + + fn openapi_ensure_bearer_auth(oa: &mut serde_json::Value) { + let Some(root) = oa.as_object_mut() else { + return; + }; + let components = root.entry("components").or_insert_with(|| json!({})); + let Some(comp) = components.as_object_mut() else { + return; + }; + let schemes = comp.entry("securitySchemes").or_insert_with(|| json!({})); + if let Some(s) = schemes.as_object_mut() { + s.entry("bearerAuth").or_insert_with(|| { + json!({ + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }) + }); + } + } + fn openapi_add_path( oa: &mut serde_json::Value, method: &str, path: &str, op_id: &str, request_schema: Option, + require_jwt: bool, + tags: Option>, ) { + let (oa_path, path_params) = Self::openapi_path_and_params(path); + if require_jwt { + Self::openapi_ensure_bearer_auth(oa); + } if let Some(paths) = oa .as_object_mut() .and_then(|m| m.get_mut("paths")) .and_then(|p| p.as_object_mut()) { let method_lc = method.to_lowercase(); - let path_entry = paths.entry(path).or_insert_with(|| json!({})); + let path_entry = paths.entry(oa_path).or_insert_with(|| json!({})); if let Some(obj) = path_entry.as_object_mut() { - let op = if let Some(schema) = request_schema { + let mut op = if let Some(schema) = request_schema { json!({ "summary": op_id, "operationId": op_id, @@ -143,6 +235,19 @@ impl App { "responses": { "200": { "description": "OK" } } }) }; + if let Some(op_obj) = op.as_object_mut() { + if !path_params.is_empty() { + op_obj.insert("parameters".to_string(), json!(path_params)); + } + if require_jwt { + op_obj.insert("security".to_string(), json!([{ "bearerAuth": [] }])); + } + if let Some(t) = tags { + if !t.is_empty() { + op_obj.insert("tags".to_string(), json!(t)); + } + } + } obj.insert(method_lc, op); } } @@ -163,7 +268,7 @@ impl App { /// Paths use **matchit 0.7** style: `/user/:id`. Pass `dependencies=[("x", get_x), ...]`. #[pyo3( - signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, read_form_body=false, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None) + signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, read_form_body=false, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None, body_model=None, tags=None) )] #[allow(clippy::too_many_arguments)] fn add_route( @@ -183,6 +288,8 @@ impl App { jwt_leeway: Option, jwt_cookie: Option, body_schema_json: Option, + body_model: Option>, + tags: Option>, ) -> PyResult<()> { { let st = self.state.read(); @@ -219,15 +326,29 @@ impl App { "require_jwt needs jwt_secret (HMAC shared secret, or public key PEM for RS*/PS*/ES*/EdDSA)", )); } - if require_jwt { - if let Some(k) = jwt_secret.as_deref() { - crate::token::build_decoding_key(k, &algs).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!( - "jwt_secret and algorithms are incompatible: {e}" - )) - })?; - } - } + let jwt_leeway_v = jwt_leeway.unwrap_or(60); + let (jwt_decoding_key, jwt_validation) = if require_jwt { + let k = jwt_secret.as_deref().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "require_jwt needs jwt_secret (HMAC shared secret, or public key PEM for RS*/PS*/ES*/EdDSA)", + ) + })?; + let (dk, val) = crate::token::build_route_jwt_state( + k, + &algs, + jwt_issuer.as_deref(), + jwt_audience.as_deref(), + jwt_leeway_v, + ) + .map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "jwt_secret and algorithms are incompatible: {e}" + )) + })?; + (Some(Arc::new(dk)), Some(Arc::new(val))) + } else { + (None, None) + }; let (dep_names, dep_factories, dep_is_async, dep_wants_request) = if let Some(d) = dependencies { parse_dependencies(py, &d)? @@ -250,15 +371,13 @@ impl App { let routes = Arc::make_mut(&mut st.routes); let idx = routes.len(); routes.push(state::RouteEntry { + path_template: path.to_string(), handler, is_async, require_jwt, - jwt_secret, - algs: Arc::<[jsonwebtoken::Algorithm]>::from(algs.clone()), - jwt_issuer, - jwt_audience, - jwt_leeway: jwt_leeway.unwrap_or(60), jwt_cookie, + jwt_decoding_key, + jwt_validation, read_json_body, read_form_body, dep_names: Arc::<[String]>::from(dep_names), @@ -268,6 +387,7 @@ impl App { handler_param_names: Arc::new(handler_param_names), handler_varkw, trivial_sync, + body_model, }); let request_schema: Option = match body_schema_json .as_deref() @@ -278,9 +398,28 @@ impl App { pyo3::exceptions::PyValueError::new_err(format!("invalid body_schema JSON: {e}")) })?), }; + let tag_list: Option> = if let Some(list) = tags { + let n = list.len(); + let mut v = Vec::with_capacity(n); + for i in 0..n { + v.push(list.get_item(i)?.extract()?); + } + Some(v) + } else { + None + }; { let mut oa = st.openapi.lock(); - App::openapi_add_path(&mut oa, &method, &path, &op_id, request_schema); + App::openapi_add_path( + &mut oa.0, + &method, + &path, + &op_id, + request_schema, + require_jwt, + tag_list, + ); + oa.1 = None; } { let mut m = state::map_method_router(&st, &method).ok_or_else(|| { @@ -346,18 +485,75 @@ impl App { fn set_openapi_title(&self, title: &str) -> PyResult<()> { let st = self.state.read(); let mut oa = st.openapi.lock(); - if let Some(info) = oa - .as_object_mut() - .and_then(|m| m.get_mut("info")) - .and_then(|i| i.as_object_mut()) + if let Some(info) = + oa.0.as_object_mut() + .and_then(|m| m.get_mut("info")) + .and_then(|i| i.as_object_mut()) { info.insert("title".to_string(), json!(title)); + oa.1 = None; } Ok(()) } + /// Enrich OpenAPI ``info`` / ``servers``. Pass JSON strings for ``contact`` and ``servers``. + #[pyo3(signature = (description=None, contact_json=None, servers_json=None))] + fn set_openapi_info( + &self, + description: Option, + contact_json: Option, + servers_json: Option, + ) -> PyResult<()> { + let st = self.state.read(); + let mut oa = st.openapi.lock(); + let Some(root) = oa.0.as_object_mut() else { + return Ok(()); + }; + if let Some(desc) = description { + if let Some(info) = root.get_mut("info").and_then(|i| i.as_object_mut()) { + info.insert("description".to_string(), json!(desc)); + } + } + if let Some(raw) = contact_json { + let contact: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid contact JSON: {e}")) + })?; + if let Some(info) = root.get_mut("info").and_then(|i| i.as_object_mut()) { + info.insert("contact".to_string(), contact); + } + } + if let Some(raw) = servers_json { + let servers: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid servers JSON: {e}")) + })?; + root.insert("servers".to_string(), servers); + } + oa.1 = None; + Ok(()) + } + /// Single optional pre-route hook. Return ``None`` to continue; otherwise the return value /// is mapped like a route handler (e.g. :class:`oxyroute.Response`, ``dict`` with ``status`` / ``body`` / ``headers``). + + #[pyo3(signature = (exc_type, handler))] + fn add_exception_handler( + &self, + exc_type: pyo3::Bound<'_, pyo3::types::PyType>, + handler: Py, + ) -> PyResult<()> { + let mut st = self.state.write(); + let is_async = pyo3::Python::with_gil(|py| -> PyResult { + let inspect = py.import("inspect")?; + inspect + .getattr("iscoroutinefunction")? + .call1((&handler,))? + .extract::() + }) + .unwrap_or(false); + Arc::make_mut(&mut st.exception_handlers).push((exc_type.unbind(), handler, is_async)); + Ok(()) + } + fn set_middleware(&self, handler: Option>) -> PyResult<()> { let mut st = self.state.write(); if let Some(h) = handler { @@ -458,8 +654,11 @@ impl App { fn openapi_json(&self) -> PyResult { let st = self.state.read(); - let oa = st.openapi.lock(); - Ok(oa.to_string()) + let mut oa = st.openapi.lock(); + if oa.1.is_none() { + oa.1 = Some(Arc::new(oa.0.to_string())); + } + Ok(oa.1.as_ref().unwrap().to_string()) } } diff --git a/src/params.rs b/src/params.rs index c1af15b..3c33063 100644 --- a/src/params.rs +++ b/src/params.rs @@ -15,43 +15,10 @@ pub fn build_request_context<'py>( method: &str, path: &str, query_string: &str, -) -> PyResult> { - let d = PyDict::new(py); - d.set_item("method", method)?; - d.set_item("path", path)?; - d.set_item("query_string", query_string)?; - d.set_item("headers", copy_scope_headers_to_dict(py, scope)?)?; - Ok(d) -} - -/// Best-effort copy of RSGI/ASGI scope `headers` into a `dict` of strings. -fn copy_scope_headers_to_dict<'py>( - py: Python<'py>, - scope: &Bound<'py, PyAny>, -) -> PyResult> { - let out = PyDict::new(py); - let h = match scope.getattr("headers") { - Ok(x) => x, - Err(_) => return Ok(out), - }; - if let Ok(inner) = h.getattr("_d") { - if let Ok(hd) = inner.downcast::() { - for (k, v) in hd.iter() { - let ks: String = k.extract()?; - let vs: String = v.extract()?; - out.set_item(ks, vs)?; - } - return Ok(out); - } - } - if let Ok(hd) = h.downcast::() { - for (k, v) in hd.iter() { - let ks: String = k.extract()?; - let vs: String = v.extract()?; - out.set_item(ks, vs)?; - } - } - Ok(out) +) -> PyResult> { + let module = py.import("oxyroute.request")?; + let req_cls = module.getattr("Request")?; + req_cls.call1((scope, method, path, query_string)) } /// Parse an HTTP `query` string (the part after `?`, without the `?`). diff --git a/src/state.rs b/src/state.rs index 15ce5f6..32828b9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use matchit::Router; @@ -38,19 +38,15 @@ pub struct WebsocketRoute { #[derive(Clone)] pub struct RouteEntry { + pub path_template: String, pub handler: Py, pub is_async: bool, pub require_jwt: bool, - pub jwt_secret: Option, - pub algs: Arc<[jsonwebtoken::Algorithm]>, - /// `None` in Python → no issuer check; else `set_issuer` in jsonwebtoken. - pub jwt_issuer: Option, - /// `None` in Python → `validate_aud` disabled for this route. - pub jwt_audience: Option, - /// Clock skew (seconds); Python `None` uses default 60 (jsonwebtoken default). - pub jwt_leeway: u64, /// If set, read JWT from the `Cookie` header when `Authorization: Bearer` is missing. pub jwt_cookie: Option, + /// Prebuilt at registration when `require_jwt` (issue #109); hot path reuses these. + pub jwt_decoding_key: Option>, + pub jwt_validation: Option>, pub read_json_body: bool, /// When set, body is parsed as form data (``application/x-www-form-urlencoded`` or ``multipart/form-data``), not JSON. pub read_form_body: bool, @@ -67,6 +63,8 @@ pub struct RouteEntry { pub handler_varkw: bool, /// Sync ``call0()`` route with no body/JWT/deps/kwargs — eligible for RSGI sync fast path. pub trivial_sync: bool, + /// Pydantic model for request body validation. + pub body_model: Option>, } /// True when the route can be served by [`try_rsgi_sync_short_circuit`](crate::dispatch::try_rsgi_sync_short_circuit) @@ -75,6 +73,8 @@ pub fn route_is_trivial_sync(entry: &RouteEntry) -> bool { entry.trivial_sync } +pub type ExceptionHandlerList = Arc, Py, bool)>>; + pub struct AppState { /// Wrapped in `Arc>` so the hot path can clone a cheap pointer **once** per request and /// release [`AppState`]'s `RwLock` immediately. Mutation goes through [`Arc::make_mut`]. @@ -88,7 +88,7 @@ pub struct AppState { pub delete: Mutex>, pub options: Mutex>, pub websocket: Mutex>, - pub openapi: Mutex, + pub openapi: Mutex<(serde_json::Value, Option>)>, /// When `Some`, route matching uses these tables without taking per-router mutexes /// (populated in [`App::freeze`](crate::App::freeze)). pub compiled: Option>, @@ -100,6 +100,7 @@ pub struct AppState { pub request_middleware: Arc>>, /// Stack of `(scope, response_dict) -> Response | dict` response hooks. Runs before CORS/Security headers. pub response_middleware: Arc>>, + pub exception_handlers: ExceptionHandlerList, /// Optional Python CORS config (e.g. :class:`oxyroute.cors.CORSConfig`) for response headers. pub cors: Option>, /// Optional :class:`oxyroute.security_headers.SecurityHeadersConfig` (or compatible @@ -113,7 +114,7 @@ impl AppState { pub fn new() -> Self { let openapi = serde_json::json!({ "openapi": "3.0.0", - "info": { "title": "OxyRoute", "version": "0.3.0" }, + "info": { "title": "OxyRoute", "version": "0.5.0" }, "paths": {} }); Self { @@ -126,12 +127,13 @@ impl AppState { delete: Mutex::new(Router::new()), options: Mutex::new(Router::new()), websocket: Mutex::new(Router::new()), - openapi: Mutex::new(openapi), + openapi: Mutex::new((openapi, None)), compiled: None, frozen: false, include_openapi: true, request_middleware: Arc::new(Vec::new()), response_middleware: Arc::new(Vec::new()), + exception_handlers: Arc::new(Vec::new()), cors: None, security_headers: None, db_pool: None, @@ -153,6 +155,7 @@ impl AppState { security_headers: self.security_headers.clone(), request_middleware: Arc::clone(&self.request_middleware), response_middleware: Arc::clone(&self.response_middleware), + exception_handlers: Arc::clone(&self.exception_handlers), include_openapi: self.include_openapi, db_pool: self.db_pool.clone(), } @@ -183,6 +186,7 @@ pub struct HotSnapshot { pub security_headers: Option>, pub request_middleware: Arc>>, pub response_middleware: Arc>>, + pub exception_handlers: ExceptionHandlerList, pub include_openapi: bool, pub db_pool: Option, } @@ -191,11 +195,11 @@ pub struct HotSnapshot { pub fn match_ws_route_compiled( compiled: &CompiledRouters, path: &str, -) -> Option<(usize, HashMap)> { +) -> Option<(usize, Vec<(String, String)>)> { compiled.websocket.at(path).ok().map(|m| { - let mut pmap = HashMap::new(); + let mut pmap = Vec::new(); for (k, v) in m.params.iter() { - pmap.insert(k.to_string(), v.to_string()); + pmap.push((k.to_string(), v.to_string())); } (*m.value, pmap) }) @@ -204,16 +208,17 @@ pub fn match_ws_route_compiled( /// Lookup an HTTP route in a precomputed [`CompiledRouters`] (lock-free). /// /// Returns ``None`` for unsupported method, ``Some(None)`` for no match, ``Some(Some(...))`` on hit. +#[allow(clippy::type_complexity)] pub fn match_route_compiled( compiled: &CompiledRouters, method: &str, path: &str, -) -> Option)>> { +) -> Option)>> { let g = router_for_compiled(compiled, method)?; Some(g.at(path).ok().map(|m| { - let mut pmap = HashMap::new(); + let mut pmap = Vec::new(); for (k, v) in m.params.iter() { - pmap.insert(k.to_string(), v.to_string()); + pmap.push((k.to_string(), v.to_string())); } (*m.value, pmap) })) @@ -344,26 +349,27 @@ fn methods_matching_path(state: &AppState, path: &str) -> Vec { /// Returns route index and path params, or `None` if the method is unsupported; `Some(None)` if /// no match; `Some(Some)` on success. Uses [`CompiledRouters`] when set (lock-free). #[cfg(test)] +#[allow(clippy::type_complexity)] fn match_route( state: &AppState, method: &str, path: &str, -) -> Option)>> { +) -> Option)>> { if let Some(c) = &state.compiled { let g = router_for_compiled(c, method)?; return Some(g.at(path).ok().map(|m| { - let mut pmap = HashMap::new(); + let mut pmap = Vec::new(); for (k, v) in m.params.iter() { - pmap.insert(k.to_string(), v.to_string()); + pmap.push((k.to_string(), v.to_string())); } (*m.value, pmap) })); } let g = map_method_router(state, method)?; Some(g.at(path).ok().map(|m| { - let mut pmap = HashMap::new(); + let mut pmap = Vec::new(); for (k, v) in m.params.iter() { - pmap.insert(k.to_string(), v.to_string()); + pmap.push((k.to_string(), v.to_string())); } (*m.value, pmap) })) @@ -385,7 +391,14 @@ mod tests { assert_eq!(pre, post); let inner = pre.expect("match"); assert_eq!(inner.0, 7); - assert_eq!(inner.1.get("id").map(String::as_str), Some("5")); + assert_eq!( + inner + .1 + .iter() + .find(|(k, _)| k == "id") + .map(|(_, v)| v.as_str()), + Some("5") + ); } #[test] diff --git a/src/token.rs b/src/token.rs index 9202dd4..9afd1e8 100644 --- a/src/token.rs +++ b/src/token.rs @@ -94,6 +94,40 @@ pub fn build_decoding_key( } } +/// Prebuild decoding key + validation template for a route (issue #109). +/// +/// Matches the former per-request setup in `dispatch`: algorithms, nbf, leeway, +/// optional issuer/audience (audience check disabled when unset). +pub fn build_route_jwt_state( + key_material: &str, + algs: &[Algorithm], + jwt_issuer: Option<&str>, + jwt_audience: Option<&str>, + jwt_leeway: u64, +) -> jsonwebtoken::errors::Result<(DecodingKey, Validation)> { + if algs.is_empty() { + return Err(jsonwebtoken::errors::Error::from( + jsonwebtoken::errors::ErrorKind::InvalidAlgorithm, + )); + } + let dk = build_decoding_key(key_material, algs)?; + let mut val = Validation::new(algs[0]); + val.algorithms = algs.to_vec(); + val.validate_nbf = true; + val.leeway = jwt_leeway; + if let Some(iss) = jwt_issuer { + val.set_issuer(&[iss]); + } + if let Some(aud) = jwt_audience { + val.set_audience(&[aud]); + } else { + // jsonwebtoken 9: with validate_aud + aud=None, a token that includes `aud` fails + // (InvalidAudience). Disable unless the route opts in to an expected audience. + val.validate_aud = false; + } + Ok((dk, val)) +} + /// Used by the request path and for golden tests against `oxyjwt.decode`. pub fn decode_hs_claims( token: &str, @@ -165,6 +199,38 @@ mod tests { use jsonwebtoken::{encode, Header}; use serde_json::json; + #[test] + fn build_route_jwt_state_hs256_roundtrip() { + let dk_val = + build_route_jwt_state("secret", &[Algorithm::HS256], None, None, 60).expect("state"); + let (dk, val) = dk_val; + let token = encode( + &Header::new(Algorithm::HS256), + &json!({ "sub": "u1", "exp": 4_000_000_000_i64 }), + &jsonwebtoken::EncodingKey::from_secret(b"secret"), + ) + .expect("encode"); + let claims = decode::(&token, &dk, &val) + .expect("decode") + .claims; + assert_eq!(claims.get("sub"), Some(&json!("u1"))); + assert!(!val.validate_aud); + } + + #[test] + fn build_route_jwt_state_sets_issuer_audience() { + let (_, val) = build_route_jwt_state( + "secret", + &[Algorithm::HS256], + Some("issuer-a"), + Some("aud-b"), + 30, + ) + .expect("state"); + assert_eq!(val.leeway, 30); + assert!(val.validate_aud); + } + #[test] fn build_decoding_key_rs256_verifies() { let priv_pem = include_str!(concat!( diff --git a/src/websocket.rs b/src/websocket.rs index ad66fde..597fda4 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -7,7 +7,6 @@ //! it would have awaited natively — no extra Tokio future bridge, no scheduling cost. //! //! [1]: https://github.com/emmett-framework/granian — see `granian/rsgi.py`. -use std::collections::HashMap; use std::sync::Arc; use parking_lot::Mutex; @@ -30,16 +29,12 @@ pub struct WebSocket { protocol: Py, scope: Py, transport: Arc>>>, - path_params: HashMap, + path_params: Vec<(String, String)>, closed: Arc>, } impl WebSocket { - pub fn new( - protocol: Py, - scope: Py, - path_params: HashMap, - ) -> Self { + pub fn new(protocol: Py, scope: Py, path_params: Vec<(String, String)>) -> Self { Self { protocol, scope, diff --git a/tests/test_405.py b/tests/test_405.py index a03f4b4..03ae87f 100644 --- a/tests/test_405.py +++ b/tests/test_405.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_405_get_on_post_only_path() -> None: diff --git a/tests/test_api_router.py b/tests/test_api_router.py index edd660a..b4a3218 100644 --- a/tests/test_api_router.py +++ b/tests/test_api_router.py @@ -9,7 +9,7 @@ import pytest from oxyroute import APIRouter, App from oxyroute.router import join_path -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_join_path() -> None: diff --git a/tests/test_cors.py b/tests/test_cors.py index c7ec48b..739168b 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, CORSConfig, apply_cors -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_cors_preflight_204_allows_post() -> None: @@ -57,3 +57,86 @@ async def _run() -> None: assert r.headers.get("access-control-allow-origin") == "https://a.example" asyncio.run(_run()) + + +class _CountingCors(CORSConfig): + """Tracks ``response_header_pairs`` calls from the native layer (issue #108).""" + + pairs_calls: int + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) # type: ignore[arg-type] + self.pairs_calls = 0 + + def response_header_pairs(self, scope: object) -> list[tuple[str, str]]: + self.pairs_calls += 1 + return super().response_header_pairs(scope) + + +def test_cors_without_origin_skips_python_pairs_call() -> None: + cfg = _CountingCors(allow_origins=["https://app.example"]) + app = App() + apply_cors(app, cfg) + + @app.get("/n") + def _n() -> str: + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/n") + assert r.status_code == 200 + assert r.text == "ok" + assert r.headers.get("access-control-allow-origin") is None + + asyncio.run(_run()) + assert cfg.pairs_calls == 0 + + +def test_cors_wildcard_without_origin_skips_pairs_with_origin_calls() -> None: + cfg = _CountingCors(allow_origins=["*"], allow_credentials=False) + app = App() + apply_cors(app, cfg) + + @app.get("/w") + def _w() -> str: + return "w" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + bare = await c.get("/w") + starred = await c.get("/w", headers={"origin": "https://any.example"}) + assert bare.status_code == 200 + assert bare.headers.get("access-control-allow-origin") is None + assert starred.status_code == 200 + assert starred.headers.get("access-control-allow-origin") == "*" + + asyncio.run(_run()) + assert cfg.pairs_calls == 1 + + +def test_cors_credentials_with_origin_still_merges() -> None: + cfg = _CountingCors( + allow_origins=["https://app.example"], + allow_credentials=True, + ) + app = App() + apply_cors(app, cfg) + + @app.get("/c") + def _c() -> str: + return "c" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + no_o = await c.get("/c") + with_o = await c.get("/c", headers={"origin": "https://app.example"}) + assert no_o.headers.get("access-control-allow-origin") is None + assert with_o.headers.get("access-control-allow-origin") == "https://app.example" + assert with_o.headers.get("access-control-allow-credentials") == "true" + + asyncio.run(_run()) + assert cfg.pairs_calls == 1 diff --git a/tests/test_cors_units.py b/tests/test_cors_units.py index 68748dc..2310b5e 100644 --- a/tests/test_cors_units.py +++ b/tests/test_cors_units.py @@ -8,7 +8,7 @@ import httpx from oxyroute import App from oxyroute.cors import CORSConfig, apply_cors -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app @dataclass diff --git a/tests/test_csrf.py b/tests/test_csrf.py index 09d66d0..f637ab4 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -8,7 +8,7 @@ import httpx from oxyroute import App from oxyroute.csrf import CSRFConfig, apply_csrf -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app _HDR = "X-CSRF-Token" CK = "oxyroute_csrf" diff --git a/tests/test_db_query.py b/tests/test_db_query.py index d45dee7..e402eb3 100644 --- a/tests/test_db_query.py +++ b/tests/test_db_query.py @@ -1,7 +1,7 @@ import httpx import pytest from oxyroute import App, DBQuery, Depends -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app @pytest.mark.anyio diff --git a/tests/test_dep_chain.py b/tests/test_dep_chain.py index 0eb026e..a470002 100644 --- a/tests/test_dep_chain.py +++ b/tests/test_dep_chain.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_dep_second_receives_first_by_name() -> None: @@ -52,6 +52,30 @@ async def run() -> None: asyncio.run(run()) +def test_dep_request_context_typed() -> None: + from oxyroute.request import Request + + def with_req(request: Request) -> str: + client = request.client or "unknown" + trace = request.headers.get("x-trace", "none") + return f"{request.method} {request.path} {client} {trace}" + + app = App() + + @app.get("/t2", dependencies=[("info", with_req)]) + def route2(info: str) -> str: + return info + + async def run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app), client=("1.2.3.4", 1234)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/t2", headers={"X-Trace": "y8"}) + assert r.status_code == 200, r.text + assert r.text == "GET /t2 1.2.3.4:1234 y8" + + asyncio.run(run()) + + def test_dep_async_chain() -> None: async def make_a() -> str: return "aa" diff --git a/tests/test_exception_handlers.py b/tests/test_exception_handlers.py new file mode 100644 index 0000000..69c6ed7 --- /dev/null +++ b/tests/test_exception_handlers.py @@ -0,0 +1,58 @@ +import asyncio + +import httpx +from oxyroute import App, Response +from oxyroute.testing import asgi_test_app + + +def test_exception_handlers(): + class CustomError(Exception): + def __init__(self, msg: str): + self.msg = msg + + class SubCustomError(CustomError): + pass + + app = App() + + # Note: the `add_exception_handler` method might be used as a decorator or a regular method. + # We didn't implement it as a decorator returning the function, but in our `app.py` it's just: + # def add_exception_handler(self, exc_type: type[BaseException], handler: Callable[..., Any]) -> None: + # So we call it directly. + + def handle_custom_error(scope, exc): + return Response(status=400, body=exc.msg.encode()) + + async def handle_sub_custom_error(scope, exc): + return {"status": 418, "body": "sub error"} + + app.add_exception_handler(CustomError, handle_custom_error) + app.add_exception_handler(SubCustomError, handle_sub_custom_error) + + @app.get("/error1") + def error1() -> str: + raise CustomError("test1") + + @app.get("/error2") + async def error2() -> str: + raise SubCustomError("test2") + + @app.get("/unhandled") + def unhandled() -> str: + raise ValueError("oh no") + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r1 = await c.get("/error1") + assert r1.status_code == 400 + assert r1.text == "test1" + + r2 = await c.get("/error2") + assert r2.status_code == 418 + assert r2.text == "sub error" + + r3 = await c.get("/unhandled") + assert r3.status_code == 500 + + asyncio.run(_run()) diff --git a/tests/test_form_body.py b/tests/test_form_body.py index 82a8453..0ca99d5 100644 --- a/tests/test_form_body.py +++ b/tests/test_form_body.py @@ -7,7 +7,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_urlencoded_form_fields() -> None: @@ -67,7 +67,7 @@ def test_payload_too_large_413() -> None: import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app os.environ["OXYROUTE_MAX_BODY_BYTES"] = "20" diff --git a/tests/test_handler_errors_500.py b/tests/test_handler_errors_500.py index 09ab7b3..a6e5f84 100644 --- a/tests/test_handler_errors_500.py +++ b/tests/test_handler_errors_500.py @@ -8,7 +8,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def _make_app() -> App: @@ -59,7 +59,7 @@ def test_500_includes_detail_when_debug_env() -> None: import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app os.environ["OXYROUTE_DEBUG"] = "1" diff --git a/tests/test_head_options.py b/tests/test_head_options.py index 129a78f..b43bd59 100644 --- a/tests/test_head_options.py +++ b/tests/test_head_options.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, Response -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_asgi_head_same_path_as_get_empty_body() -> None: diff --git a/tests/test_header_sanitization.py b/tests/test_header_sanitization.py index 6d59ac9..df7e1d4 100644 --- a/tests/test_header_sanitization.py +++ b/tests/test_header_sanitization.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, HTTPException, Response -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_response_header_crlf_is_rejected_with_500() -> None: diff --git a/tests/test_http_exception.py b/tests/test_http_exception.py index cae3e25..3a853e6 100644 --- a/tests/test_http_exception.py +++ b/tests/test_http_exception.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, HTTPException -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_http_exception_404_string_detail() -> None: diff --git a/tests/test_json_body.py b/tests/test_json_body.py index 139346f..bade8cd 100644 --- a/tests/test_json_body.py +++ b/tests/test_json_body.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_json_body_nested_types() -> None: diff --git a/tests/test_jwt_cookie.py b/tests/test_jwt_cookie.py index 2148b9b..6f22140 100644 --- a/tests/test_jwt_cookie.py +++ b/tests/test_jwt_cookie.py @@ -8,7 +8,7 @@ import httpx import pytest from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app oxyjwt = pytest.importorskip("oxyjwt") diff --git a/tests/test_jwt_route_iss_aud.py b/tests/test_jwt_route_iss_aud.py index 2e95545..ff1dd0d 100644 --- a/tests/test_jwt_route_iss_aud.py +++ b/tests/test_jwt_route_iss_aud.py @@ -8,7 +8,7 @@ import httpx import pytest from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app oxyjwt = pytest.importorskip("oxyjwt") diff --git a/tests/test_jwt_rs256.py b/tests/test_jwt_rs256.py index 3632f5a..54507a4 100644 --- a/tests/test_jwt_rs256.py +++ b/tests/test_jwt_rs256.py @@ -10,7 +10,7 @@ import jwt import pytest from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app _FIX = Path(__file__).resolve().parent / "fixtures" / "rsa" _PUB = (_FIX / "public_pkcs8.pem").read_text() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 5830f5f..94ce8b1 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, Response -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_middleware_cors_preflight_204_no_route_ran() -> None: diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 9f7cffe..9a1446c 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -3,8 +3,8 @@ import httpx import pytest -from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute import APIRouter, App +from oxyroute.testing import asgi_test_app def test_openapi_shows_route() -> None: @@ -16,8 +16,19 @@ def list_items() -> str: s = app.openapi_json() assert "paths" in s - assert "/items/:i" in s + assert "/items/{i}" in s + assert "/items/:i" not in s assert "T" in s + doc = json.loads(s) + params = doc["paths"]["/items/{i}"]["get"]["parameters"] + assert params == [ + { + "name": "i", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ] def test_openapi_includes_patch_lowercase() -> None: @@ -31,6 +42,106 @@ def m() -> str: assert doc["paths"]["/m"]["patch"]["operationId"] == "m" +def test_openapi_jwt_security_scheme() -> None: + app = App() + + @app.get("/public") + def public() -> str: + return "ok" + + @app.get("/secret", require_jwt=True, jwt_secret="test-secret-key") + def secret(claims: dict) -> str: + return "ok" + + doc = json.loads(app.openapi_json()) + schemes = doc["components"]["securitySchemes"] + assert schemes["bearerAuth"]["type"] == "http" + assert schemes["bearerAuth"]["scheme"] == "bearer" + assert schemes["bearerAuth"]["bearerFormat"] == "JWT" + assert doc["paths"]["/secret"]["get"]["security"] == [{"bearerAuth": []}] + assert "security" not in doc["paths"]["/public"]["get"] + + +def test_openapi_tags_from_route_and_include_router() -> None: + r = APIRouter() + + @r.get("/a", tags=["alpha"]) + def a() -> str: + return "a" + + app = App() + app.include_router(r, prefix="/api", tags=["shared"]) + + @app.get("/b", tags=["beta"]) + def b() -> str: + return "b" + + doc = json.loads(app.openapi_json()) + # include_router merges defaults then per-route opts — route tags win over defaults + assert doc["paths"]["/api/a"]["get"]["tags"] == ["alpha"] + assert doc["paths"]["/b"]["get"]["tags"] == ["beta"] + + +def test_openapi_include_router_default_tags() -> None: + r = APIRouter() + + @r.get("/x") + def x() -> str: + return "x" + + app = App() + app.include_router(r, prefix="/v1", tags=["v1"]) + doc = json.loads(app.openapi_json()) + assert doc["paths"]["/v1/x"]["get"]["tags"] == ["v1"] + + +def test_openapi_set_info_and_servers() -> None: + app = App( + title="API", + openapi_description="Demo", + openapi_contact={"name": "Ops", "email": "ops@example.com"}, + openapi_servers=[{"url": "https://api.example.com", "description": "prod"}], + ) + doc = json.loads(app.openapi_json()) + assert doc["info"]["description"] == "Demo" + assert doc["info"]["contact"]["email"] == "ops@example.com" + assert doc["servers"][0]["url"] == "https://api.example.com" + + +def test_docs_ui_scalar_returns_html() -> None: + app = App(title="DocsApp", docs_ui="scalar") + + @app.get("/ping") + def ping() -> str: + return "pong" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/docs") + assert r.status_code == 200 + assert "text/html" in r.headers.get("content-type", "") + assert "/openapi.json" in r.text + assert "scalar" in r.text.lower() or "api-reference" in r.text + + asyncio.run(_run()) + + +def test_docs_ui_swagger_via_mount_docs() -> None: + app = App(title="Swag") + app.mount_docs("/swagger", ui="swagger") + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/swagger") + assert r.status_code == 200 + assert "text/html" in r.headers.get("content-type", "") + assert "swagger-ui" in r.text.lower() + + asyncio.run(_run()) + + def test_openapi_serving_off_constructor_get_and_head_404_openapi_json_still_filled() -> None: app = App(title="S", include_openapi=False) diff --git a/tests/test_query_decode.py b/tests/test_query_decode.py index 98bc82c..f9674a7 100644 --- a/tests/test_query_decode.py +++ b/tests/test_query_decode.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_query_value_percent_decoded() -> None: diff --git a/tests/test_routing_autocompile.py b/tests/test_routing_autocompile.py index 19cbcc9..97fd67a 100644 --- a/tests/test_routing_autocompile.py +++ b/tests/test_routing_autocompile.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_routes_added_after_first_request_still_resolve() -> None: diff --git a/tests/test_rsgi_lifespan.py b/tests/test_rsgi_lifespan.py index 9776c05..a24c6f6 100644 --- a/tests/test_rsgi_lifespan.py +++ b/tests/test_rsgi_lifespan.py @@ -1,4 +1,4 @@ -"""RSGI lifespan hooks: subclassing ``App`` (issue #18).""" +"""RSGI lifespan hooks: ``on_startup`` / Granian-compatible sync init (issue #18 / #130).""" from __future__ import annotations @@ -18,13 +18,45 @@ def test_app_state_is_simple_namespace() -> None: def test_base_rsgi_init_and_del_are_noop() -> None: async def _go() -> None: a = App() + # No-arg: returns coroutine (TestClient / await style). await a.__rsgi_init__() await a.__rsgi_del__() asyncio.run(_go()) -def test_subclass_rsgi_init_can_set_state() -> None: +def test_on_startup_sets_state() -> None: + class WorkerApp(App): + async def on_startup(self) -> None: + self.marker = 7 + + async def _go() -> None: + a = WorkerApp() + assert not hasattr(a, "marker") + await a.__rsgi_init__() + assert a.marker == 7 + + asyncio.run(_go()) + + +def test_granian_style_sync_init_with_non_running_loop() -> None: + class WorkerApp(App): + async def on_startup(self) -> None: + self.state.ready = True + + a = WorkerApp() + loop = asyncio.new_event_loop() + try: + # Granian: sync call with a non-running loop. + result = a.__rsgi_init__(loop) + assert result is None + assert a.state.ready is True + a.__rsgi_del__(loop) + finally: + loop.close() + + +def test_subclass_legacy_async_rsgi_init_still_awaitable() -> None: class WorkerApp(App): async def __rsgi_init__(self, *args, **kwargs) -> None: self.marker = 7 @@ -32,7 +64,6 @@ async def __rsgi_init__(self, *args, **kwargs) -> None: async def _go() -> None: a = WorkerApp() - assert not hasattr(a, "marker") await a.__rsgi_init__() assert a.marker == 7 diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index 49634d3..491d21e 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, SecurityHeadersConfig -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_security_headers_merged_on_get() -> None: diff --git a/tests/test_sse.py b/tests/test_sse.py index ce6b44c..97edd9c 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -6,7 +6,7 @@ import httpx from oxyroute import App, send_sse -from tests._rsgi_test_transport import asgi_test_app +from oxyroute.testing import asgi_test_app def test_sse_response_body_and_content_type() -> None: diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 0000000..53a6b38 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,66 @@ +import os +from tempfile import TemporaryDirectory + +from oxyroute import App, StaticFiles +from oxyroute.testing import TestClient + + +def test_static_files(): + with TemporaryDirectory() as tmpdir: + with open(os.path.join(tmpdir, "test.txt"), "w") as f: + f.write("hello world") + + app = App() + app.mount("/static", StaticFiles(tmpdir)) + + with TestClient(app) as client: + resp = client.get("/static/test.txt") + assert resp.status_code == 200 + assert resp.content == b"hello world" + assert resp.headers["content-type"] == "text/plain" + + +def test_static_files_missing(): + with TemporaryDirectory() as tmpdir: + app = App() + app.mount("/static", StaticFiles(tmpdir)) + + with TestClient(app) as client: + resp = client.get("/static/missing.txt") + assert resp.status_code == 404 + + +def test_static_files_traversal(): + with TemporaryDirectory() as tmpdir: + # Create a file outside the static directory + with open(os.path.join(tmpdir, "secret.txt"), "w") as f: + f.write("secret") + + static_dir = os.path.join(tmpdir, "static") + os.makedirs(static_dir) + + app = App() + app.mount("/static", StaticFiles(static_dir)) + + with TestClient(app) as client: + resp = client.get("/static/../secret.txt") + assert resp.status_code in (403, 404) + + +def test_static_files_index_html(): + with TemporaryDirectory() as tmpdir: + with open(os.path.join(tmpdir, "index.html"), "w") as f: + f.write("

Hello

") + + app = App() + app.mount("/static", StaticFiles(tmpdir, html=True)) + + with TestClient(app) as client: + resp = client.get("/static/") + assert resp.status_code == 200 + assert resp.content == b"

Hello

" + assert resp.headers["content-type"] == "text/html" + + resp = client.get("/static") + assert resp.status_code == 200 + assert resp.content == b"

Hello

" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..508a54a --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,76 @@ +from oxyroute import App, stream_bytes, stream_jsonl, stream_text +from oxyroute.testing import TestClient + + +def test_stream_text(): + app = App() + + @app.get("/text") + async def text_handler(protocol): + def _iter(): + yield "hello" + yield " " + yield "world" + + return await stream_text(protocol, _iter()) + + with TestClient(app) as client: + resp = client.get("/text") + assert resp.status_code == 200 + assert resp.text == "hello world" + assert resp.headers["content-type"] == "text/plain; charset=utf-8" + + +def test_stream_bytes(): + app = App() + + @app.get("/bytes") + async def bytes_handler(protocol): + def _iter(): + yield b"123" + yield b"456" + + return await stream_bytes(protocol, _iter(), status=201, headers=[("x-custom", "foo")]) + + with TestClient(app) as client: + resp = client.get("/bytes") + assert resp.status_code == 201 + assert resp.content == b"123456" + assert resp.headers["content-type"] == "application/octet-stream" + assert resp.headers["x-custom"] == "foo" + + +def test_stream_jsonl(): + app = App() + + @app.get("/jsonl") + async def jsonl_handler(protocol): + def _iter(): + yield {"id": 1, "name": "foo"} + yield {"id": 2, "name": "bar"} + + return await stream_jsonl(protocol, _iter()) + + with TestClient(app) as client: + resp = client.get("/jsonl") + assert resp.status_code == 200 + assert resp.text == '{"id": 1, "name": "foo"}\n{"id": 2, "name": "bar"}\n' + assert resp.headers["content-type"] == "application/x-ndjson; charset=utf-8" + + +def test_stream_async_iter(): + app = App() + + @app.get("/async") + async def async_handler(protocol): + async def _iter(): + yield "async" + yield " " + yield "iter" + + return await stream_text(protocol, _iter()) + + with TestClient(app) as client: + resp = client.get("/async") + assert resp.status_code == 200 + assert resp.text == "async iter" diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..0e25d99 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,57 @@ +import asyncio + +import httpx +from oxyroute import App +from oxyroute.testing import asgi_test_app +from pydantic import BaseModel + + +class UserBody(BaseModel): + name: str + age: int + + +def test_body_model_validation_success() -> None: + app = App() + seen: dict[str, object] = {} + + @app.post("/user", body_model=UserBody) + def create_user(json: UserBody) -> str: + seen["user"] = json + return json.name + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post("/user", json={"name": "Alice", "age": 30}) + assert r.status_code == 200, r.text + assert r.text == "Alice" + + asyncio.run(_run()) + user = seen["user"] + assert isinstance(user, UserBody) + assert user.name == "Alice" + assert user.age == 30 + + +def test_body_model_validation_failure_422() -> None: + app = App() + + @app.post("/user", body_model=UserBody) + def create_user(json: UserBody) -> str: + return json.name + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + # Missing 'age', should fail validation + r = await c.post("/user", json={"name": "Bob"}) + + assert r.status_code == 422 + data = r.json() + assert "detail" in data + assert isinstance(data["detail"], list) + assert data["detail"][0]["type"] == "missing" + assert data["detail"][0]["loc"] == ["age"] + + asyncio.run(_run()) diff --git a/uv.lock b/uv.lock index 62c5ee1..ec23ca5 100644 --- a/uv.lock +++ b/uv.lock @@ -417,7 +417,7 @@ wheels = [ [[package]] name = "oxyroute" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } [package.optional-dependencies] @@ -425,6 +425,7 @@ bench = [ { name = "fastapi" }, { name = "granian" }, { name = "httpx" }, + { name = "pyjwt" }, ] dev = [ { name = "cryptography" }, @@ -449,6 +450,7 @@ requires-dist = [ { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.4,<2" }, { name = "oxyjwt", marker = "extra == 'dev'", specifier = ">=0.2" }, { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2" }, + { name = "pyjwt", marker = "extra == 'bench'", specifier = ">=2.8" }, { name = "pyjwt", marker = "extra == 'dev'", specifier = ">=2.8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },