From ebe1a996ebe9a5819819de7bb5b457bf1dd986e2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 23:20:20 +0300 Subject: [PATCH 1/3] feat: form bodies (urlencoded + multipart) with read_form_body (#47) - Route flag read_form_body; inject form (dict) and files (list of part dicts) - Parse with form_urlencoded crate and multer; 400/415 on bad input; 413 when body exceeds OXYROUTE_MAX_BODY_BYTES (default 8 MiB; 0 disables) - Mutual exclusion with read_json_body in Rust and app._route - Tests: httpx ASGI urlencoded, multipart upload, oversize 413 - docs/handlers.md + feature.md; PRIORITIES marks #47 done Closes #47 --- .github/ISSUE_BACKLOG/PRIORITIES.md | 4 +- Cargo.lock | 69 +++++++++++++++++ Cargo.toml | 3 + docs/feature.md | 4 +- docs/handlers.md | 15 +++- oxyroute/app.py | 19 ++++- src/dispatch.rs | 111 +++++++++++++++++++++++++++- src/form.rs | 92 +++++++++++++++++++++++ src/lib.rs | 10 ++- src/state.rs | 2 + tests/test_form_body.py | 83 +++++++++++++++++++++ 11 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 src/form.rs create mode 100644 tests/test_form_body.py diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index afc9d3e..7f76bd0 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -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) @@ -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. 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/feature.md b/docs/feature.md index caa2347..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 для всех типов контента. | 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/oxyroute/app.py b/oxyroute/app.py index b682e50..9902cc7 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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()) @@ -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, diff --git a/src/dispatch.rs b/src/dispatch.rs index 6edee30..79a78b0 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -9,6 +9,7 @@ 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; @@ -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); @@ -203,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, @@ -226,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(), @@ -336,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() { @@ -357,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> { @@ -467,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 c5a7899..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, diff --git a/src/state.rs b/src/state.rs index 13a8776..8e3417f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -43,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>, 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 From d90e3181cb24976f502b98708af3da9765d74dd8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 23:26:33 +0300 Subject: [PATCH 2/3] fix --- tests/test_form_body.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_form_body.py b/tests/test_form_body.py index 0962aec..fbcb4a2 100644 --- a/tests/test_form_body.py +++ b/tests/test_form_body.py @@ -63,6 +63,7 @@ def b(form: dict) -> str: 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: @@ -72,7 +73,9 @@ async def _run() -> None: 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 {} + 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()) From f6e324c7755ed64f3960dceabd850c673083a5d3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 23:35:54 +0300 Subject: [PATCH 3/3] fix --- src/form.rs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/form.rs b/src/form.rs index 5ebc6b0..712e245 100644 --- a/src/form.rs +++ b/src/form.rs @@ -41,15 +41,8 @@ pub async fn parse_multipart(body: Vec, boundary: &str) -> Result = 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(); + 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 @@ -57,11 +50,7 @@ pub async fn parse_multipart(body: Vec, boundary: &str) -> Result::new(); - while let Some(chunk) = field - .chunk() - .await - .map_err(|e| e.to_string())? - { + while let Some(chunk) = field.chunk().await.map_err(|e| e.to_string())? { data.extend_from_slice(&chunk); } files.push(ParsedFile {