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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/ISSUE_BACKLOG/PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +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. |
| 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)

Expand Down Expand Up @@ -36,10 +35,11 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
- **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, 47; **18** done — `app.state` + lifespan docs (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.

Expand Down
69 changes: 69 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 для всех типов контента. |

Expand Down
15 changes: 14 additions & 1 deletion docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 17 additions & 2 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,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,
Expand All @@ -103,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,
Expand All @@ -119,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,
Expand All @@ -134,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,
Expand All @@ -148,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,
Expand All @@ -166,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,
Expand All @@ -182,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,
Expand Down Expand Up @@ -211,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,
Expand Down Expand Up @@ -238,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,
Expand All @@ -254,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,
Expand All @@ -266,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())
Expand All @@ -278,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,
Expand Down
Loading
Loading