diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc index 8014995..ae6a770 100644 --- a/.cursor/rules/git-workflow.mdc +++ b/.cursor/rules/git-workflow.mdc @@ -1,26 +1,42 @@ --- -description: Feature branches and PRs for GitHub issues — never land feature work by committing straight to dev/main +description: Issue workflow — sync dev, branch, implement, test+lint, atomic commits, PR to dev alwaysApply: true --- # Git and GitHub workflow (OxyRoute) -When implementing a **feature, fix, or issue** (including backlog / issue numbers in `.github/ISSUE_BACKLOG`): +**Integration branch is `dev`.** Do not land feature or fix work by committing directly to `dev` or `main` (see exceptions at the end). -1. **Branch** - - Do **not** commit or push feature work directly to `dev` or `main`. - - Create a branch from the current base (usually `dev`): `git fetch origin` then `git checkout -b `. - - Prefer names like: `feat/`, or `issue--` (e.g. `issue-20-head-options`). +## Order of operations (always) -2. **Pull request** - - Push the branch: `git push -u origin `. - - Open a **PR** into `dev` (or the repo’s default integration branch). - - In the PR title or description, **link the issue**: `Closes #N` / `Fixes #N` / `Ref #N` as appropriate so GitHub closes or tracks the work. +1. **Sync `dev` first** + - `git fetch origin` + - `git checkout dev` + - `git pull origin dev` -3. **Before coding** - - Confirm whether an issue already exists; if the user only gave a topic, match it to a backlog file or open an issue, then use the same branch+PR flow. +2. **Branch from that `dev` for one GitHub issue** (one topic per PR when possible) + - `git checkout -b issue--` (e.g. `issue-47-multipart-form`) -4. **Exceptions** - - Tiny doc typo or rule-only changes may still use a small branch+PR, or a single “chore:” PR if the team agrees — default is **always branch + PR** for any code or test change. +3. **Read the requirement** + - Open the GitHub issue and, if present, the body under [`.github/ISSUE_BACKLOG/bodies/`](.github/ISSUE_BACKLOG/bodies/) or linked docs. -This applies to all agents and humans working in this repo. +4. **Implement** in the branch (Rust/Python/docs as needed). + +5. **Validate before commit/push** (match CI in [`.github/workflows/ci.yml`](.github/workflows/ci.yml)) + - Python: `uv run ruff check oxyroute tests examples` and `uv run ruff format --check oxyroute tests examples` (or format then check). + - Rust: `cargo fmt --all -- --check` and `cargo clippy --all-targets -- -D warnings` + - Tests: `uv run pytest` (or the same isolated pattern as in `docs/development.md` if testing the installed wheel). + +6. **Atomic commits** — one logical change per commit (e.g. `feat:`, `fix:`, `test:`, `docs:`). **Do not** mix product code and [`.github/ISSUE_BACKLOG/`](.github/ISSUE_BACKLOG/) in the same commit; backlog updates: separate `docs:` / `chore:` commit if needed. + +7. **PR into `dev`** + - `git push -u origin ` + - Open PR: **base = `dev`**, compare = your branch. Use **`Closes #N`** (or `Fixes #N`) when the PR completes issue **N**. + +8. **After merge** — delete the local branch, keep `main` and `dev`; next issue: repeat from step 1. + +## Exceptions + +- Trivial one-line doc fixes may still use a small branch + PR, or a maintainer chore — default is **branch + PR** for any code or test change. + +[Full narrative + `gh` examples: `docs/development-workflow.md`] diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index 51ef7ed..7f76bd0 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -8,8 +8,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next |---|------|-----------| | 4 | [04.md](bodies/04.md) | **Route hot path** — reduce lock contention / route snapshot after `freeze()`. | | 17 | [17.md](bodies/17.md) | **ASGI bridge** — `run_coroutine_threadsafe` / thread safety under concurrent load. | -| 18 | [18.md](bodies/18.md) | **App state / lifespan** — shared resources across workers. | -| 22 | [22.md](bodies/22.md) | **Multipart / urlencoded body** — [GitHub #47](https://github.com/QueryaHub/OxyRoute/issues/47). | ## P1 (API ergonomics, security helpers, protocol features) @@ -36,10 +34,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next - **Query, errors, E2E:** 2, 3, 12 - **API surface:** 1, 5, 6, 7, 10, 11, 19, 20 - **CI / release / docs / PyO3:** 13, 14, 15, 16 +- **Lifespan / `app.state`:** 18 (see `App.state`, `examples/rsgi_lifespan_app.py`, `docs/rsgi.md`) +- **Form bodies:** 22 / [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — `read_form_body`, `form` / `files` kwargs, `docs/handlers.md` ## Roadmap phasing (summary) -1. **P0:** 4, 17, 18, 47 (order flexible). +1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible). 2. **P1:** 8, 46, 48, 49, 53, 54; 9 as polish. 3. **Research:** 50, 51, 52 — as capacity allows. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d34db0..3f3f65b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,11 +42,12 @@ cargo clippy ## Branches, PRs, and atomic commits -- **Integration branch is `dev`:** create your branch from `dev`, open PRs with **base = `dev`**. The maintainer merges to `dev` and later promotes to `main` for releases as needed. -- **One issue, one PR (when possible):** name the branch with the issue number, e.g. `issue-2-query-decode` or `feat/2-query-decode`. +- **Integration branch is `dev`:** always `git fetch origin`, `git checkout dev`, `git pull origin dev` **before** creating a branch. Open PRs with **base = `dev`**; maintainers promote to `main` for releases as needed. +- **One issue, one PR (when possible):** name the branch with the issue number, e.g. `issue-2-query-decode` or `feat/2-query-decode`. Read the GitHub issue (and backlog body if linked) before coding. +- After implementation, run **the same tests and linters as CI** (Ruff, `cargo fmt`/`clippy`, pytest) — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml). - Use **atomic commits** (one logical change per commit). **Do not** put `.github/ISSUE_BACKLOG/` changes in the same commit as product code—backlog/templating updates should be a separate `docs:` (or `chore:`) commit, or a separate PR. - Prefer **conventional commits**: `feat:`, `fix:`, `docs:`, `ci:`, `chore:`, `test:`. -- Full step-by-step: [docs/development-workflow.md](docs/development-workflow.md). +- Full checklist: [docs/development-workflow.md](docs/development-workflow.md). ## Creating GitHub issues from the template backlog diff --git a/Cargo.lock b/Cargo.lock index 10dffa0..ad60fe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.61" @@ -51,6 +57,15 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -173,6 +188,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "indoc" version = "2.0.7" @@ -255,6 +286,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -300,10 +354,13 @@ name = "oxyroute" version = "0.1.0" dependencies = [ "base64", + "bytes", "form_urlencoded", + "futures-util", "jsonwebtoken", "log", "matchit", + "multer", "once_cell", "parking_lot", "pyo3", @@ -572,6 +629,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "syn" version = "2.0.117" @@ -687,6 +750,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 561bb51..e660c52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,9 @@ base64 = "0.22" once_cell = "1.19" # RFC 1738 / WHATWG: percent-decode and + as space in application/x-www-form-urlencoded query form_urlencoded = "1.2" +bytes = "1" +futures-util = { version = "0.3", default-features = false, features = ["std"] } +multer = "3.1" log = "0.4" parking_lot = "0.12" diff --git a/docs/asgi.md b/docs/asgi.md index bd7b647..a0f49d2 100644 --- a/docs/asgi.md +++ b/docs/asgi.md @@ -8,9 +8,11 @@ The module `oxyroute.asgi` builds a minimal RSGI-shaped `scope` / `protocol` and ## How responses get back to ASGI -The RSGI response helpers on the protocol object are **synchronous** from the Rust side. The ASGI implementation schedules `send` coroutines on the **current asyncio event loop** that was running when the request was accepted (`asyncio.run_coroutine_threadsafe` + `result()`), so the event loop in the process must be free to make progress when native code issues responses. +The RSGI response helpers on the protocol object are **synchronous** from the Rust side. The ASGI implementation schedules `send` coroutines on the **ASGI process’s** event loop: that loop is captured in the protocol and native code uses `asyncio.run_coroutine_threadsafe` + `result()` to run each `send` on it. -**Implications:** This bridge is a **practical adapter**, not a full reimplementation of RSGI under every ASGI host. Prefer **RSGI/Granian** for production with OxyRoute if you can. +**Why the bridge does not `await` on that loop directly:** Awaiting the native `handle_rsgi` coroutine on the same loop that must drain `run_coroutine_threadsafe` can **deadlock** (the loop is blocked in `await` and never runs the scheduled `send` tasks). The bridge therefore runs `handle_rsgi` in a **thread-pool** worker using `asyncio.run()` on a **separate** event loop, while the protocol still targets the main ASGI loop for `send` scheduling. + +**Implications:** This bridge is a **practical adapter**, not a full reimplementation of RSGI under every ASGI host. Prefer **RSGI/Granian** for production with OxyRoute if you can. With **Uvicorn**, use a **single worker** process unless you are sure the combination is safe in your app (the usual `workers=N` + in-process `asyncio` footguns can still apply to third-party `asyncio` use). ## Limitations (current) @@ -20,7 +22,7 @@ The RSGI response helpers on the protocol object are **synchronous** from the Ru ## Local testing -The **httpx** project’s `ASGITransport` is used in `tests/test_asgi.py` to exercise the bridge without a real TCP server. That test is a good template for a smoke check. +The **httpx** project’s `ASGITransport` is used in `tests/test_asgi.py` to exercise the bridge without a real TCP server. `tests/test_asgi_stress.py` runs **50** concurrent `GET` requests to guard against cross-thread/loop deadlocks in the bridge. ## See also diff --git a/docs/dependencies.md b/docs/dependencies.md index 8b0340c..ef5ff2b 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -44,7 +44,7 @@ The underlying callables are unwrapped in Python and passed to the native `add_r ## Freezing route registration -Calling **`app.freeze()`** (forwarded to the native `App`) sets the app to **no longer accept** new routes—use this when you want a final route table before serving (future-proofing for DI graphs and similar). +Calling **`app.freeze()`** (forwarded to the native `App`) sets the app to **no longer accept** new routes—use this when you want a final route table before serving (future-proofing for DI graphs and similar). The native layer also **clones the per-method `matchit` routers** into a read-only snapshot so that **path matching no longer takes per-router mutexes** on the hot request path (the mutable copies remain for introspection alignment with the snapshot). ## See also diff --git a/docs/development-workflow.md b/docs/development-workflow.md index 2927541..b606d42 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -2,41 +2,43 @@ OxyRoute uses **`dev` as the integration branch**. `main` (or the default production branch) is updated from `dev` when maintainers cut a release or merge a stable snapshot—follow what your team documents for release tagging. -## Branching model - -1. **Always branch from the latest `dev`:** +## Checklist (order matters) +1. **Sync `dev` first (always `fetch` + `pull` before branching):** ```bash git fetch origin git checkout dev git pull origin dev ``` +2. **Create a branch** for one GitHub issue: `git checkout -b issue--`. +3. **Read the issue** (and [`.github/ISSUE_BACKLOG/bodies/`](.github/ISSUE_BACKLOG/bodies/) or linked spec if you use it). +4. **Implement** on that branch. +5. **Run tests and linters** (same as CI, see [`.github/workflows/ci.yml`](../.github/workflows/ci.yml)): + - `uv run ruff check oxyroute tests examples` and `uv run ruff format --check oxyroute tests examples` + - `cargo fmt --all -- --check` and `cargo clippy --all-targets -- -D warnings` + - `uv run pytest` (or the isolated pattern in [development.md](development.md)) +6. **Commit atomically** (one logical change per commit: `feat:`, `fix:`, `test:`, `docs:`, …). +7. **Push and open a PR to `dev`** (base = `dev`), with **`Closes #N`** when the issue is done. -2. **Create a branch for one GitHub issue** (one topic per PR when possible): - - ```bash - git checkout -b issue-12-rsgi-e2e - # or: feat/12-rsgi-e2e — use a short, kebab-case slug - ``` +## Branch naming - Prefer including the **issue number** in the name so the PR and issue stay linked (e.g. #12). +- Prefer `issue--` or `feat/-` so the PR links to the issue (e.g. #12). -3. **Implement the issue** with **small, atomic commits** (one logical change per commit: `fix: …`, `test: …`, `docs: …`). +## Atomic commits and backlog -4. **Do not mix** code changes and **`.github/ISSUE_BACKLOG/`** (issue template bodies) in the same commit. The backlog is reference material for maintainers; if you need to update it, use a **separate** commit, e.g. `docs: update issue backlog description`. +- **Do not mix** product code and **`.github/ISSUE_BACKLOG/`** in the same commit. If you update templates, use a **separate** `docs:` or `chore:` commit (or a separate PR). -5. **Push and open a pull request** with **base = `dev`** and **compare = your branch**. +## Push and open a pull request - ```bash - git push -u origin issue-12-rsgi-e2e - ``` +```bash +git push -u origin issue-12-rsgi-e2e +``` - On GitHub: **New pull request** → base repository `QueryaHub/OxyRoute`, **base: `dev`**, **compare: `issue-12-rsgi-e2e`**. +On GitHub: **New pull request** → **base: `dev`**, **compare: your branch**. Use **`Closes #N`** (or `Fixes #N`) to auto-close the issue on merge. -6. In the PR description, use **`Closes #N`** (or `Fixes #N`) if the work fully finishes issue **N**—GitHub will close the issue when the PR is merged. +## After merge -7. After merge into `dev`, delete the remote branch (GitHub can do this on merge) and locally: - `git branch -d issue-12-rsgi-e2e` , then continue with the next issue from a fresh `dev`. +Delete the remote branch (GitHub can do this on merge) and locally: `git branch -d issue-12-rsgi-e2e`. For the **next** issue, repeat from **fetch + pull `dev`**. ## What not to do diff --git a/docs/feature.md b/docs/feature.md index 66ea521..77699d5 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -33,8 +33,8 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| -| **`multipart/form-data`** | Нет | Загрузка файлов, поля формы — нужен парсер и API (`upload`, `form()`). | -| **`application/x-www-form-urlencoded` (body)** | Нет | Сейчас разбор query есть; тело формы не инжектится как отдельный тип. | +| **`multipart/form-data`** | Частично | `read_form_body=True`, инжект `form` / `files` (в памяти); см. [handlers](handlers.md). [#47](https://github.com/QueryaHub/OxyRoute/issues/47). | +| **`application/x-www-form-urlencoded` (body)** | Частично | То же — `form`; нет streaming для очень больших тел. | | **Streaming request body** | Ограничено | Тело читается в буфер для JSON/сырых байт; большие upload без полного чтения — отдельная работа. | | **Валидация на уровне фреймворка** | Частично | Pydantic удобнее вручную; нет единого `Body()` как в FastAPI для всех типов контента. | @@ -68,7 +68,7 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| -| **Встроенный `app.state` / lifespan** | Слабо | `__rsgi_init__` и общий state — [#18](https://github.com/QueryaHub/OxyRoute/issues/18). | +| **Встроенный `app.state` / lifespan** | Частично | `App.state` (`SimpleNamespace`), хуки `__rsgi_init__` / `__rsgi_del__`, пример `examples/rsgi_lifespan_app.py` — закрыто в [#18](https://github.com/QueryaHub/OxyRoute/issues/18). | | **Сессии (signed cookie, server-side)** | Нет | | | **Кэш глобальных настроек** | Нет | Нет первого класса для config. | diff --git a/docs/handlers.md b/docs/handlers.md index d298b08..21855e6 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -17,12 +17,25 @@ handler(**kwargs) | Path parameters | Matched from the route, e.g. `id` for `/items/:id` | String-like values, with coercion for `int` / `float` / `bool` / `str` in the Rust layer where applicable | | `query` | Request has a query string | A Python `dict` of string keys and values, **percent-decoded**; `+` in values is treated as a space, matching `application/x-www-form-urlencoded` / URLSearchParams ([WHATWG](https://url.spec.whatwg.org/#urlencoded-parsing)). **Duplicate keys** are last-wins (a plain `dict`, not a multimap) | | `json` | `read_json_body` is true and body parses as JSON | `dict`/list/values as converted from `serde_json` to Python | -| `body` | Raw body bytes, when JSON is not used or empty | `bytes` | +| `form` | `read_form_body` is true on **POST**, **PUT**, **PATCH**, or **DELETE** | `dict` of string keys to string values (same last-wins semantics as `query`). Parsed from `application/x-www-form-urlencoded` or non-file parts of `multipart/form-data` | +| `files` | `read_form_body` is true and the request is `multipart/form-data` | `list` of `dict`s with `name`, `filename` (`str` or missing), `content_type`, and `data` (`bytes`). In-memory only; there is no streaming spool to disk in the current implementation | +| `body` | Raw body bytes when JSON and form modes are not used | `bytes`. **Not** passed when `read_form_body` is enabled (use `form` / `files` instead) | | `claims` | `require_jwt` is true and the JWT validates | The decoded JSON claims as a Python object (typically a `dict`) | | Named dependencies | `dependencies=[("name", factory), ...]` | Return value of each factory, in order (see [dependencies.md](dependencies.md)). Only dependencies whose **names** appear on the route handler’s signature (or `**kwargs`) are passed to the handler—intermediate-only dependencies are not forwarded | **JWT:** if `require_jwt` is set but validation fails, the **handler is not called**; the response is 401 (or a dedicated “Expired” string for expired signature when applicable). See [jwt.md](jwt.md). +### Form bodies (`read_form_body`) + +Register routes with `read_form_body=True` on `post` / `put` / `patch` (and `delete` if you accept a body). This is **mutually exclusive** with `read_json_body` for the same route (the framework forces JSON off when form mode is on). + +- **`Content-Type: application/x-www-form-urlencoded`** — the body is parsed like a query string (percent-decoding, `+` as space). +- **`Content-Type: multipart/form-data; boundary=...`** — parts with a **filename** are collected as `files`; other parts go into `form`. +- Wrong or missing `Content-Type` when the body is non-empty → **400** (missing type) or **415** (not a form type). Malformed multipart → **400** with a JSON error. +- **Size limit:** the full body is buffered in memory before parsing. The default maximum is **8 MiB**; override with the environment variable **`OXYROUTE_MAX_BODY_BYTES`** (set to `0` to disable the check—**not recommended** in production). Oversized bodies → **413** with `{"error":"payload too large"}`. + +Only parameters that appear in the handler signature (or `**kwargs`) receive `form` / `files`, the same as for `query` and dependencies. + ## Sync and async Both **synchronous** and **asynchronous** callables are supported. If a handler is a coroutine function, the returned awaitable is run on the async runtime integration used by the extension. diff --git a/docs/rsgi.md b/docs/rsgi.md index 6ab0292..a6f709c 100644 --- a/docs/rsgi.md +++ b/docs/rsgi.md @@ -25,6 +25,8 @@ The Python `oxyroute.app.App` class implements the async RSGI entry that Granian You can **override** these in a **subclass** of `App` to open DB pools, HTTP clients, `asyncio` primitives, etc. The default base implementation does nothing. +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. + ### 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`. diff --git a/examples/rsgi_lifespan_app.py b/examples/rsgi_lifespan_app.py index 1bd5647..6deed60 100644 --- a/examples/rsgi_lifespan_app.py +++ b/examples/rsgi_lifespan_app.py @@ -6,34 +6,57 @@ granian --interface rsgi examples.rsgi_lifespan_app:app ``examples/rsgi_app.py`` is the minimal app. This file shows **subclassing** ``App`` to -open resources when the host starts a worker. In-memory fields are **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 **cross-machine** state. +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). + +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 +**cross-machine** state. See https://github.com/QueryaHub/OxyRoute/blob/main/docs/rsgi.md (lifespan section). """ from __future__ import annotations +import asyncio import time +from concurrent.futures import ThreadPoolExecutor from oxyroute import App +_WORKERS = 4 # per-process pool size; not shared across ``granian --workers N`` + class LifespanApp(App): - """Example: set attributes when the RSGI worker calls ``__rsgi_init__``.""" + """Example: attach per-process state when the RSGI worker calls ``__rsgi_init__``.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.ready_at: float | None = None + # ``state`` is a :class:`types.SimpleNamespace` on the base :class:`App`. + self.state.ready_at = None + self.state.thread_pool = None async def __rsgi_init__(self, *args, **kwargs) -> None: - # Place per-process setup here (DB engine, pool, clients, asyncio primitives). - self.ready_at = time.time() + # ``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, ...)`` + # from async glue, or keep sync handlers cheap and use the pool in dependencies. + self.state.thread_pool = ThreadPoolExecutor( + max_workers=_WORKERS, + thread_name_prefix="rsgi", + ) + self.state.ready_at = time.time() return None async def __rsgi_del__(self, *args, **kwargs) -> None: - self.ready_at = None + pool = self.state.thread_pool + if pool is not None: + pool.shutdown(wait=True) + self.state.ready_at = None + self.state.thread_pool = None + if hasattr(self.state, "bg_limit"): + del self.state.bg_limit return None @@ -42,7 +65,7 @@ async def __rsgi_del__(self, *args, **kwargs) -> None: @app.get("/") def root() -> str: - t = app.ready_at + t = app.state.ready_at if t is None: return "ok (lifespan not run — use a real RSGI server)" return f"ok boot_timestamp={t:.4f}" @@ -51,5 +74,13 @@ def root() -> str: @app.get("/meta") def meta() -> str: """In multi-worker mode each worker has its own ``ready_at`` (not a global counter).""" - t = app.ready_at + t = app.state.ready_at return f"ready_at={t}" + + +@app.get("/pool") +def pool_info() -> str: + p = app.state.thread_pool + if p is None: + return "thread_pool=off" + return f"thread_pool max_workers={_WORKERS} (per process, not across workers)" diff --git a/oxyroute/app.py b/oxyroute/app.py index 0d1a772..9902cc7 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -2,6 +2,7 @@ import json from collections.abc import Callable, Mapping +from types import SimpleNamespace from typing import Any, TypeVar from . import _oxyroute @@ -34,12 +35,18 @@ class App: """ Application object: pass a module instance to Granian, e.g. ``granian app:app --interface rsgi`` or for ASGI ``granian app:app --interface asgi``. + + ``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. """ def __init__(self, title: str = "OxyRoute", *, include_openapi: bool = True) -> 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.state: SimpleNamespace = SimpleNamespace() self._asgi3: Callable[..., Any] = build_asgi_caller(self) def freeze(self) -> None: @@ -82,6 +89,7 @@ def get( algorithms, read_json_body=False, dependencies=dependencies, + read_form_body=False, jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, @@ -96,6 +104,7 @@ def post( jwt_secret: str | None = None, algorithms: list[str] | None = None, read_json_body: bool = True, + read_form_body: bool = False, jwt_issuer: str | None = None, jwt_audience: str | None = None, jwt_leeway: int | None = None, @@ -112,6 +121,7 @@ def post( algorithms, read_json_body, dependencies=dependencies, + read_form_body=read_form_body, jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, @@ -127,6 +137,8 @@ def put( require_jwt: bool = False, jwt_secret: str | None = None, algorithms: list[str] | None = None, + read_json_body: bool = True, + read_form_body: bool = False, jwt_issuer: str | None = None, jwt_audience: str | None = None, jwt_leeway: int | None = None, @@ -141,8 +153,9 @@ def put( require_jwt, jwt_secret, algorithms, - read_json_body=True, + read_json_body, dependencies=dependencies, + read_form_body=read_form_body, jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, @@ -159,6 +172,7 @@ def patch( jwt_secret: str | None = None, algorithms: list[str] | None = None, read_json_body: bool = True, + read_form_body: bool = False, jwt_issuer: str | None = None, jwt_audience: str | None = None, jwt_leeway: int | None = None, @@ -175,6 +189,7 @@ def patch( algorithms, read_json_body, dependencies=dependencies, + read_form_body=read_form_body, jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, @@ -204,6 +219,7 @@ def delete( algorithms, read_json_body=False, dependencies=dependencies, + read_form_body=False, jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, @@ -231,6 +247,7 @@ def options( algorithms, read_json_body=False, dependencies=dependencies, + read_form_body=False, jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, @@ -247,6 +264,7 @@ def _route( read_json_body: bool, dependencies: list[tuple[str, Dep]] | None, *, + read_form_body: bool = False, jwt_issuer: str | None = None, jwt_audience: str | None = None, jwt_leeway: int | None = None, @@ -259,6 +277,9 @@ def _route( def wrap(handler: F) -> F: if body_model is not None and body_schema is not None: raise TypeError("use only one of body_model and body_schema") + rj = read_json_body + if read_form_body: + rj = False body_schema_json: str | None = None if body_model is not None: body_schema_json = json.dumps(body_model.model_json_schema()) @@ -271,7 +292,8 @@ def wrap(handler: F) -> F: require_jwt, jwt_secret, algorithms, - read_json_body, + rj, + read_form_body, dlist, jwt_issuer, jwt_audience, diff --git a/oxyroute/asgi.py b/oxyroute/asgi.py index 7bca951..cbd3377 100644 --- a/oxyroute/asgi.py +++ b/oxyroute/asgi.py @@ -12,6 +12,28 @@ from typing import Any +def _run_handle_rsgi_blocking( + app_rsgi: Callable[[Any, Any], Any], + rscope: Any, + proto: Any, +) -> None: + """Run ``handle_rsgi`` to completion on a **fresh** event loop in the thread pool. + + The ASGI server's main loop must **not** ``await`` the native coroutine directly: the + Rust/Tokio side calls synchronous ``protocol.response_*`` which use + ``run_coroutine_threadsafe(..., self._loop).result()`` targeting the **main** loop. + If the main loop is blocked in ``await handle_rsgi``, it can never run those ``send`` + coroutines — a classic deadlock. Running the await in ``run_in_executor`` + ``asyncio.run`` + keeps the main loop free to drain the threadsafe queue. + """ + + async def _inner() -> None: + c = app_rsgi(rscope, proto) + await c + + asyncio.run(_inner()) + + def _hdr_from_asgi(raw: list[tuple[bytes, bytes]]) -> _HeaderView: d: dict[str, str] = {} for k, v in raw: @@ -204,7 +226,13 @@ async def asgi_to_rsgi( ) loop = asyncio.get_running_loop() proto = _RsgiProtocol(body, send, loop) - await app_rsgi(rscope, proto) + await loop.run_in_executor( + None, + _run_handle_rsgi_blocking, + app_rsgi, + rscope, + proto, + ) def build_asgi_caller(framework_app: Any) -> Callable[..., Any]: diff --git a/src/dispatch.rs b/src/dispatch.rs index a5f5fde..79a78b0 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -9,10 +9,11 @@ use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; use serde_json::Value as JsonValue; +use crate::form::{self, ParsedFile}; use crate::params::{build_request_context, header_get_lax, parse_query, value_for_path_param}; use crate::response; use crate::schema::json_to_py; -use crate::state::{map_method_router, methods_matching_path, AppState}; +use crate::state::{match_route, methods_matching_path, AppState}; use crate::token::{build_decoding_key, extract_bearer, extract_cookie_value}; type RouteCallSnapshot = ( @@ -25,7 +26,8 @@ type RouteCallSnapshot = ( Option, u64, Option, - bool, + bool, // read_json_body + bool, // read_form_body Vec, Vec>, Vec, @@ -157,6 +159,16 @@ pub async fn run_rsgi( } Ok(Vec::new()) })?; + let max = form::max_body_bytes(); + if (body_bytes.len() as u64) > max { + return response::send_text( + &protocol, + 413, + r#"{"error":"payload too large"}"#, + "application/json; charset=utf-8", + ) + .await; + } let (auth, cookie_raw) = Python::with_gil(|py| -> PyResult<(Option, Option)> { let s = scope.bind(py); @@ -166,18 +178,13 @@ pub async fn run_rsgi( header_get_lax(&headers, "cookie"), )) })?; - let route_out: Option<(usize, HashMap)> = (|| -> PyResult<_> { + let route_out: Option<(usize, HashMap)> = { let st = state.read(); - let g = map_method_router(&st, &method) - .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("method"))?; - Ok(g.at(&path).ok().map(|m| { - let mut pmap = HashMap::new(); - for (k, v) in m.params.iter() { - pmap.insert(k.to_string(), v.to_string()); - } - (*m.value, pmap) - })) - })()?; + match match_route(&st, &method, &path) { + None => Err(pyo3::exceptions::PyValueError::new_err("method")), + Some(m) => Ok(m), + } + }?; let (route_idx, param_map) = match route_out { Some(x) => x, None => { @@ -208,6 +215,7 @@ pub async fn run_rsgi( jwt_leeway, jwt_cookie, read_json_body, + read_form_body, dep_names, dep_factories, dep_is_async, @@ -231,6 +239,7 @@ pub async fn run_rsgi( e.jwt_leeway, e.jwt_cookie.clone(), e.read_json_body, + e.read_form_body, e.dep_names.clone(), e.dep_factories.clone(), e.dep_is_async.clone(), @@ -341,6 +350,7 @@ pub async fn run_rsgi( } let query_map = parse_query(&query_string); let body_json: Option = if read_json_body + && !read_form_body && (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") { if body_bytes.is_empty() { @@ -362,6 +372,75 @@ pub async fn run_rsgi( } else { None }; + let (form_map, form_files): (HashMap, Vec) = if read_form_body + && (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") + { + if body_bytes.is_empty() { + (HashMap::new(), vec![]) + } else { + let ct: PyResult> = Python::with_gil(|py| { + let s = scope.bind(py); + let headers = s.getattr("headers")?; + Ok(header_get_lax(&headers, "content-type")) + }); + let ct = match ct { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; + if ct.as_deref().map(str::is_empty) != Some(false) { + return response::send_text( + &protocol, + 400, + r#"{"error":"missing content-type"}"#, + "application/json; charset=utf-8", + ) + .await; + } + let cts = ct.unwrap(); + let lower = cts.to_ascii_lowercase(); + if lower.starts_with("application/x-www-form-urlencoded") { + (form::parse_urlencoded_form(&body_bytes), vec![]) + } else if lower.starts_with("multipart/form-data") { + let boundary = match multer::parse_boundary(&cts) { + Ok(b) => b, + Err(_) => { + return response::send_text( + &protocol, + 400, + r#"{"error":"invalid multipart boundary"}"#, + "application/json; charset=utf-8", + ) + .await + } + }; + let parsed = match form::parse_multipart(body_bytes.clone(), &boundary).await { + Ok(p) => p, + Err(e) => { + return response::send_text( + &protocol, + 400, + &format!(r#"{{"error":"multipart: {e}"}}"#), + "application/json; charset=utf-8", + ) + .await + } + }; + (parsed.form, parsed.files) + } else { + return response::send_text( + &protocol, + 415, + r#"{"error":"expected application/x-www-form-urlencoded or multipart/form-data"}"#, + "application/json; charset=utf-8", + ) + .await; + } + } + } else { + (HashMap::new(), vec![]) + }; let need_req_ctx = dep_wants_request.iter().any(|&x| x); let request_ctx: Option> = if need_req_ctx { match Python::with_gil(|py| -> PyResult> { @@ -472,7 +551,30 @@ pub async fn run_rsgi( let pyv = json_to_py(py, j.clone())?; kwargs.set_item("json", pyv)?; } - if !body_bytes.is_empty() && body_json.is_none() { + if read_form_body { + if handler_varkw || handler_param_names.contains("form") { + let fd = PyDict::new(py); + for (k, v) in &form_map { + fd.set_item(k, v.as_str())?; + } + kwargs.set_item("form", fd)?; + } + if handler_varkw || handler_param_names.contains("files") { + let fl = PyList::empty(py); + for f in &form_files { + let d = PyDict::new(py); + d.set_item("name", f.name.as_str())?; + match &f.filename { + Some(n) => d.set_item("filename", n.as_str())?, + None => d.set_item("filename", py.None())?, + } + d.set_item("content_type", f.content_type.as_str())?; + d.set_item("data", PyBytes::new(py, &f.data))?; + fl.append(d)?; + } + kwargs.set_item("files", fl)?; + } + } else if !body_bytes.is_empty() && body_json.is_none() { kwargs.set_item("body", PyBytes::new(py, &body_bytes))?; } let res = handler.bind(py).call((), Some(&kwargs))?.unbind(); diff --git a/src/form.rs b/src/form.rs new file mode 100644 index 0000000..5ebc6b0 --- /dev/null +++ b/src/form.rs @@ -0,0 +1,92 @@ +//! `application/x-www-form-urlencoded` and `multipart/form-data` parsing (issue #47). + +use std::collections::HashMap; +use std::convert::Infallible; + +use bytes::Bytes; +use form_urlencoded::parse as parse_urlencoded; +use futures_util::stream; +use multer::Multipart; + +/// Per-part file entry from ``multipart/form-data`` (in-memory, whole body is already bounded). +pub struct ParsedFile { + pub name: String, + pub filename: Option, + pub content_type: String, + pub data: Vec, +} + +pub struct ParsedFormData { + pub form: HashMap, + pub files: Vec, +} + +/// Same semantics as query parsing: last wins for duplicate keys. +pub fn parse_urlencoded_form(body: &[u8]) -> HashMap { + let mut m = HashMap::new(); + if body.is_empty() { + return m; + } + for (k, v) in parse_urlencoded(body) { + m.insert(k.into_owned(), v.into_owned()); + } + m +} + +/// Parse a full multipart body; ``boundary`` is from ``Content-Type`` (see ``multer::parse_boundary``). +pub async fn parse_multipart(body: Vec, boundary: &str) -> Result { + let b = body; + let stream = stream::once(async move { Result::::Ok(Bytes::from(b)) }); + let mut multipart = Multipart::new(stream, boundary); + let mut form: HashMap = HashMap::new(); + let mut files: Vec = Vec::new(); + + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| e.to_string())? + { + let name = field + .name() + .unwrap_or("field") + .to_string(); + if field.file_name().is_some() { + let filename = field.file_name().map(str::to_string); + let content_type = field + .content_type() + .map(|m| m.essence_str().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + let mut data = Vec::::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|e| e.to_string())? + { + data.extend_from_slice(&chunk); + } + files.push(ParsedFile { + name, + filename, + content_type, + data, + }); + } else { + let text = field.text().await.map_err(|e| e.to_string())?; + form.insert(name, text); + } + } + Ok(ParsedFormData { form, files }) +} + +/// Default 8 MiB; ``0`` = no limit (not recommended in production). Override with env ``OXYROUTE_MAX_BODY_BYTES``. +pub fn max_body_bytes() -> u64 { + const DEFAULT: u64 = 8 * 1024 * 1024; + match std::env::var("OXYROUTE_MAX_BODY_BYTES") + .ok() + .and_then(|s| s.parse().ok()) + { + None => DEFAULT, + Some(0) => u64::MAX, + Some(n) => n, + } +} diff --git a/src/lib.rs b/src/lib.rs index 3e6c33c..e94c94e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ use pyo3::types::{PyDict, PyList, PyTuple}; use serde_json::json; mod dispatch; +mod form; mod params; mod response; mod schema; @@ -159,7 +160,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, 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) )] #[allow(clippy::too_many_arguments)] fn add_route( @@ -172,6 +173,7 @@ impl App { jwt_secret: Option, algorithms: Option>, read_json_body: bool, + read_form_body: bool, dependencies: Option>, jwt_issuer: Option, jwt_audience: Option, @@ -204,6 +206,11 @@ impl App { if algs.is_empty() { algs.push(jsonwebtoken::Algorithm::HS256); } + if read_json_body && read_form_body { + return Err(pyo3::exceptions::PyValueError::new_err( + "read_json_body and read_form_body are mutually exclusive", + )); + } if require_jwt && jwt_secret.is_none() { return Err(pyo3::exceptions::PyValueError::new_err( "require_jwt needs jwt_secret (HMAC shared secret, or public key PEM for RS*/PS*/ES*/EdDSA)", @@ -242,6 +249,7 @@ impl App { jwt_leeway: jwt_leeway.unwrap_or(60), jwt_cookie, read_json_body, + read_form_body, dep_names, dep_factories, dep_is_async, @@ -276,6 +284,9 @@ impl App { fn freeze(&self) -> PyResult<()> { let mut st = self.state.write(); st.frozen = true; + if st.compiled.is_none() { + st.compiled = Some(Arc::new(st.snapshot_routers())); + } Ok(()) } diff --git a/src/state.rs b/src/state.rs index db4c258..8e3417f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,9 +1,33 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use matchit::Router; use parking_lot::Mutex; use pyo3::prelude::*; +/// Immutable route tables built at [`AppState::freeze`](AppState) time so the request +/// path can be matched without per-method `Mutex` locks (issue #4). +pub struct CompiledRouters { + pub get: Router, + pub post: Router, + pub put: Router, + pub patch: Router, + pub delete: Router, + pub options: Router, +} + +fn router_for_compiled<'a>(c: &'a CompiledRouters, method: &str) -> Option<&'a Router> { + match method { + "GET" | "HEAD" => Some(&c.get), + "POST" => Some(&c.post), + "PUT" => Some(&c.put), + "PATCH" => Some(&c.patch), + "DELETE" => Some(&c.delete), + "OPTIONS" => Some(&c.options), + _ => None, + } +} + pub struct RouteEntry { pub handler: Py, pub is_async: bool, @@ -19,6 +43,8 @@ pub struct RouteEntry { /// If set, read JWT from the `Cookie` header when `Authorization: Bearer` is missing. pub jwt_cookie: 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, /// Dependency `name` -> factory callable (linear order; resolved in order, then user handler). pub dep_names: Vec, pub dep_factories: Vec>, @@ -41,6 +67,9 @@ pub struct AppState { pub delete: Mutex>, pub options: Mutex>, pub openapi: Mutex, + /// When `Some`, route matching uses these tables without taking per-router mutexes + /// (populated in [`App::freeze`](crate::App::freeze)). + pub compiled: Option>, /// When true, `add_route` fails (DAG / routes frozen for DI). pub frozen: bool, /// Serve `GET /openapi.json` from the built document without a user route. @@ -65,11 +94,24 @@ impl AppState { delete: Mutex::new(Router::new()), options: Mutex::new(Router::new()), openapi: Mutex::new(openapi), + compiled: None, frozen: false, include_openapi: true, middleware: None, } } + + /// Clone current mutex-protected [`Router`]s into a snapshot (used at freeze / tests). + pub fn snapshot_routers(&self) -> CompiledRouters { + CompiledRouters { + get: self.get.lock().clone(), + post: self.post.lock().clone(), + put: self.put.lock().clone(), + patch: self.patch.lock().clone(), + delete: self.delete.lock().clone(), + options: self.options.lock().clone(), + } + } } pub fn map_method_router<'a>( @@ -95,42 +137,64 @@ pub fn map_method_router<'a>( pub fn methods_matching_path(state: &AppState, path: &str) -> Vec { const ORDER: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; let mut have = [false; 7]; - { - let g = state.get.lock(); - if g.at(path).is_ok() { + if let Some(c) = &state.compiled { + if c.get.at(path).is_ok() { have[0] = true; have[1] = true; } - } - { - let r = state.post.lock(); - if r.at(path).is_ok() { + if c.post.at(path).is_ok() { have[2] = true; } - } - { - let r = state.put.lock(); - if r.at(path).is_ok() { + if c.put.at(path).is_ok() { have[3] = true; } - } - { - let r = state.patch.lock(); - if r.at(path).is_ok() { + if c.patch.at(path).is_ok() { have[4] = true; } - } - { - let r = state.delete.lock(); - if r.at(path).is_ok() { + if c.delete.at(path).is_ok() { have[5] = true; } - } - { - let r = state.options.lock(); - if r.at(path).is_ok() { + if c.options.at(path).is_ok() { have[6] = true; } + } else { + { + let g = state.get.lock(); + if g.at(path).is_ok() { + have[0] = true; + have[1] = true; + } + } + { + let r = state.post.lock(); + if r.at(path).is_ok() { + have[2] = true; + } + } + { + let r = state.put.lock(); + if r.at(path).is_ok() { + have[3] = true; + } + } + { + let r = state.patch.lock(); + if r.at(path).is_ok() { + have[4] = true; + } + } + { + let r = state.delete.lock(); + if r.at(path).is_ok() { + have[5] = true; + } + } + { + let r = state.options.lock(); + if r.at(path).is_ok() { + have[6] = true; + } + } } ORDER .iter() @@ -139,3 +203,59 @@ pub fn methods_matching_path(state: &AppState, path: &str) -> Vec { .map(|(m, _)| (*m).to_string()) .collect() } + +/// 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). +pub fn match_route( + state: &AppState, + method: &str, + path: &str, +) -> 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(); + for (k, v) in m.params.iter() { + pmap.insert(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(); + for (k, v) in m.params.iter() { + pmap.insert(k.to_string(), v.to_string()); + } + (*m.value, pmap) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn match_route_same_with_and_without_compiled() { + let mut s = AppState::new(); + s.get.lock().insert("/a/:id", 7usize).unwrap(); + let pre = match_route(&s, "GET", "/a/5").expect("method"); + + s.compiled = Some(Arc::new(s.snapshot_routers())); + let post = match_route(&s, "GET", "/a/5").expect("method"); + + 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")); + } + + #[test] + fn methods_matching_path_uses_compiled() { + let mut s = AppState::new(); + s.post.lock().insert("/x", 0usize).unwrap(); + s.compiled = Some(Arc::new(s.snapshot_routers())); + let m = methods_matching_path(&s, "/x"); + assert_eq!(m, vec!["POST".to_string()]); + } +} diff --git a/tests/test_asgi_stress.py b/tests/test_asgi_stress.py new file mode 100644 index 0000000..e6bad66 --- /dev/null +++ b/tests/test_asgi_stress.py @@ -0,0 +1,29 @@ +"""Concurrent ASGI requests (issue #17) — no deadlock on many parallel clients.""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App + + +def test_asgi_50_concurrent_gets() -> None: + async def _run() -> None: + app = App() + + @app.get("/c") + def c() -> str: + return "ok" + + async def one(client: httpx.AsyncClient) -> int: + r = await client.get("/c") + return r.status_code + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + tasks = [one(client) for _ in range(50)] + codes = await asyncio.gather(*tasks) + assert all(c == 200 for c in codes) + + asyncio.run(_run()) diff --git a/tests/test_form_body.py b/tests/test_form_body.py new file mode 100644 index 0000000..0962aec --- /dev/null +++ b/tests/test_form_body.py @@ -0,0 +1,83 @@ +"""`application/x-www-form-urlencoded` and `multipart/form-data` (issues #22 / #47).""" + +from __future__ import annotations + +import asyncio +import os + +import httpx +from oxyroute import App + + +def test_urlencoded_form_fields() -> None: + app = App() + + @app.post("/form", read_form_body=True) + def form_route(form: dict) -> str: + return f"{form.get('a')},{form.get('b')}" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/form", + content="a=1&b=two%20here", + headers={"content-type": "application/x-www-form-urlencoded; charset=utf-8"}, + ) + assert r.status_code == 200, r.text + assert r.text == "1,two here" + + asyncio.run(_run()) + + +def test_multipart_file_and_field() -> None: + app = App() + + @app.post("/up", read_form_body=True) + def up(form: dict, files: list) -> str: + f0 = files[0] + data = f0["data"] + return f"note={form.get('note')};name={f0['name']};len={len(data)}" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/up", + data={"note": "n1"}, + files={"f": ("a.txt", b"hello", "text/plain")}, + ) + assert r.status_code == 200, r.text + assert r.text == "note=n1;name=f;len=5" + + asyncio.run(_run()) + + +def test_payload_too_large_413() -> None: + app = App() + + @app.post("/b", read_form_body=True) + def b(form: dict) -> str: + return "ok" if not form else "bad" + + old = os.environ.get("OXYROUTE_MAX_BODY_BYTES") + os.environ["OXYROUTE_MAX_BODY_BYTES"] = "20" + try: + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/b", + content="x" * 100, + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + assert r.status_code == 413 + err = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} + assert err.get("error") == "payload too large" or "payload" in (r.text or "") + + asyncio.run(_run()) + finally: + if old is None: + del os.environ["OXYROUTE_MAX_BODY_BYTES"] + else: + os.environ["OXYROUTE_MAX_BODY_BYTES"] = old diff --git a/tests/test_rsgi_lifespan.py b/tests/test_rsgi_lifespan.py index 1a04419..9776c05 100644 --- a/tests/test_rsgi_lifespan.py +++ b/tests/test_rsgi_lifespan.py @@ -3,10 +3,18 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace from oxyroute import App +def test_app_state_is_simple_namespace() -> None: + a = App() + assert isinstance(a.state, SimpleNamespace) + a.state.pool = "mock" + assert a.state.pool == "mock" + + def test_base_rsgi_init_and_del_are_noop() -> None: async def _go() -> None: a = App()