From 48b1100ca904b9f47c50fec5d8efacd5b01ab86d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 00:05:13 +0300 Subject: [PATCH 1/3] feat: CORSConfig, apply_cors, native response header merge (Closes #49) - Add oxyroute.cors with CORSConfig, preflightResponse, apply_cors(chain=) - App.set_cors and exports; Rust merges response_header_pairs after handlers/middleware - Fix Py clone under GIL, PyO3 0.25 extract + merge_cors_to_handler_map - tests/test_cors.py, docs/cors.md, backlog and feature table updates --- .github/ISSUE_BACKLOG/PRIORITIES.md | 4 +- .github/ISSUE_BACKLOG/README.md | 2 +- docs/cors.md | 58 +++++++++++ docs/feature.md | 5 +- docs/handlers.md | 3 + docs/index.md | 1 + oxyroute/__init__.py | 3 + oxyroute/app.py | 8 ++ oxyroute/cors.py | 153 ++++++++++++++++++++++++++++ src/dispatch.rs | 78 ++++++++++++++ src/lib.rs | 7 ++ src/state.rs | 3 + tests/test_cors.py | 58 +++++++++++ 13 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 docs/cors.md create mode 100644 oxyroute/cors.py create mode 100644 tests/test_cors.py diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index fabd6d0..29afec7 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -15,7 +15,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next |---|------|-----------| | 8 | [08.md](bodies/08.md) | **JWK / JWKS** — [GitHub #8](https://github.com/QueryaHub/OxyRoute/issues/8). | | 9 | [09.md](bodies/09.md) | **OpenAPI depth** (optional) — `$ref` / `$defs`. | -| 24 | [24.md](bodies/24.md) | **CORS helper** — [GitHub #49](https://github.com/QueryaHub/OxyRoute/issues/49). | | 28 | [28.md](bodies/28.md) | **CSRF** (optional) — [GitHub #53](https://github.com/QueryaHub/OxyRoute/issues/53). | | 29 | [29.md](bodies/29.md) | **Security headers preset** — [GitHub #54](https://github.com/QueryaHub/OxyRoute/issues/54). | @@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next - **Form bodies:** 22 / [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — `read_form_body`, `form` / `files` kwargs, `docs/handlers.md` - **HTTPException:** 23 / [#48](https://github.com/QueryaHub/OxyRoute/issues/48) — `oxyroute.exceptions`, `docs/handlers.md` (per-type `register_exception_handler` not in scope) - **Sub-routers:** 21 / [#46](https://github.com/QueryaHub/OxyRoute/issues/46) — `APIRouter`, `include_router`, `docs/routing.md` +- **CORS:** 24 / [#49](https://github.com/QueryaHub/OxyRoute/issues/49) — `CORSConfig`, `apply_cors`, `set_cors`, `docs/cors.md` ## Roadmap phasing (summary) 1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible). -2. **P1:** 8, 49, 53, 54; **#48** / **#46** done; 9 as polish. +2. **P1:** 8, 53, 54; **#48** / **#46** / **#49 (CORS)** done; 9 as polish. 3. **Research:** 50, 51, 52 — as capacity allows. [← Back to README](README.md) diff --git a/.github/ISSUE_BACKLOG/README.md b/.github/ISSUE_BACKLOG/README.md index a69a997..486cc4c 100644 --- a/.github/ISSUE_BACKLOG/README.md +++ b/.github/ISSUE_BACKLOG/README.md @@ -4,7 +4,7 @@ This directory holds **20 + N issue bodies** ([bodies/](bodies/)) and a [PRIORIT ## Status and GitHub (living backlog) -- **Active milestone [v0.2.0](https://github.com/QueryaHub/OxyRoute/milestone/1):** next **PyPI 0.2.0** — open work includes [#4](https://github.com/QueryaHub/OxyRoute/issues/4) (perf), [#8](https://github.com/QueryaHub/OxyRoute/issues/8) (JWK/oxyjwt), [#17](https://github.com/QueryaHub/OxyRoute/issues/17) (ASGI), [#18](https://github.com/QueryaHub/OxyRoute/issues/18) (lifespan/state), [#46](https://github.com/QueryaHub/OxyRoute/issues/46)–[#49](https://github.com/QueryaHub/OxyRoute/issues/49) (sub-routers, multipart, **global exceptions**, CORS), [#50](https://github.com/QueryaHub/OxyRoute/issues/50)–[#54](https://github.com/QueryaHub/OxyRoute/issues/54) (HTTP/2 docs, **SSE**, **WebSocket** research, **CSRF**, **security headers**). +- **Active milestone [v0.2.0](https://github.com/QueryaHub/OxyRoute/milestone/1):** next **PyPI 0.2.0** — open work includes [#4](https://github.com/QueryaHub/OxyRoute/issues/4) (perf), [#8](https://github.com/QueryaHub/OxyRoute/issues/8) (JWK/oxyjwt), [#17](https://github.com/QueryaHub/OxyRoute/issues/17) (ASGI), [#18](https://github.com/QueryaHub/OxyRoute/issues/18) (lifespan/state), [#46](https://github.com/QueryaHub/OxyRoute/issues/46)–[#48](https://github.com/QueryaHub/OxyRoute/issues/48) (sub-routers, multipart, **global exceptions**), [#50](https://github.com/QueryaHub/OxyRoute/issues/50)–[#54](https://github.com/QueryaHub/OxyRoute/issues/54) (HTTP/2 docs, **SSE**, **WebSocket** research, **CSRF**, **security headers**). - **Closed milestone [v0.3.0](https://github.com/QueryaHub/OxyRoute/milestone/2):** unused name; work consolidated under **v0.2.0**. - Optional OpenAPI depth (`$ref` / `$defs`): [09.md](bodies/09.md); issue #9 is closed — open a new issue if you pick this up. - **Do not re-run** `./scripts/create-github-issues.sh` on an already-populated repo (duplicates). Check open work with: `gh issue list -R QueryaHub/OxyRoute --state open`. diff --git a/docs/cors.md b/docs/cors.md new file mode 100644 index 0000000..d34ab67 --- /dev/null +++ b/docs/cors.md @@ -0,0 +1,58 @@ +# CORS + +[← Documentation index](index.md) + +Cross-origin resource sharing is supported in two layers: + +1. **`CORSConfig` + `apply_cors(app, config)`** — sets the config on the native app (so successful route and middleware responses get CORS headers merged) and installs a **pre-route** middleware that answers **CORS preflight** (`OPTIONS` with `Access-Control-Request-Method`) without reading the body. +2. **`App.set_cors(config)`** — only registers the config for response header merging; you must still handle preflight yourself (e.g. with `set_middleware`) if browsers need it. + +## Basic usage + +```python +from oxyroute import App, CORSConfig, apply_cors + +app = App() +apply_cors( + app, + CORSConfig( + allow_origins=["https://my.frontend.example"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["*"], + ), +) + +@app.get("/api/x") +def x() -> dict: + return {"ok": True} +``` + +## Combining with another middleware + +OxyRoute exposes a **single** pre-route hook (`set_middleware`). Calling `apply_cors` replaces that hook with an internal function. To run your own logic **after** CORS preflight is ruled out, pass **`chain=`**: + +```python +def my_mw(scope, protocol): + # runs only when apply_cors did not return a preflight response + return None + +apply_cors(app, config, chain=my_mw) +``` + +If you need the opposite order, call `set_middleware` yourself and use `set_cors` only, or call `set_middleware` with a function that calls your code first, then delegates preflight to `config.preflight_response(scope)`. + +## Configuration fields + +| Field | Role | +|--------|------| +| `allow_origins` | List of allowed `Origin` values, or `["*"]` when not using credentials. | +| `allow_methods` | HTTP methods allowed in preflight and echoed in `Access-Control-Allow-Methods`. | +| `allow_headers` | `["*"]` or a list of permitted request header names for preflight. | +| `expose_headers` | Optional list; sent as `Access-Control-Expose-Headers` on real responses. | +| `allow_credentials` | If true, `Access-Control-Allow-Credentials: true` and `*` cannot be used as the origin. | +| `max_age` | Seconds for `Access-Control-Max-Age` on preflight, or `None` to omit. | + +## See also + +- [Handlers](handlers.md) — `set_middleware` and return mapping +- [RSGI and Granian](rsgi.md) diff --git a/docs/feature.md b/docs/feature.md index b949591..fb95762 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -56,7 +56,7 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| -| **CORS** | Ручной | В доках упомянут preflight через `set_middleware`; нет готового `CORSMiddleware` с настройками. | +| **CORS** | `CORSConfig` + `apply_cors` / `set_cors` | Preflight и заголовки на ответах; см. [cors.md](cors.md). | | **CSRF** | Нет | Для cookie-сессий и форм часто нужны токены. | | **Rate limiting** | Нет | | | **Security headers** (HSTS, CSP, …) | Ручные заголовки | Нет пресетов. | @@ -112,7 +112,7 @@ OxyRoute осознанно **уже** в нише: **быстрый маршр 3. **Перф роутера** [#4](https://github.com/QueryaHub/OxyRoute/issues/4) — при росте нагрузки. 4. **Sub-routers или префиксы** — резко повышают пригодность для крупных приложений. 5. **Multipart + form body** — если не только JSON API. -6. **CORS / exception handlers** — быстрые победы на Python-стороне без ломки RSGI. +6. **Exception handlers (глобальные)** — быстрые победы на Python-стороне без ломки RSGI. **CORS** — см. [cors.md](cors.md) / [#49](https://github.com/QueryaHub/OxyRoute/issues/49). ## Связанные GitHub-issues (milestone v0.2.0) @@ -121,7 +121,6 @@ OxyRoute осознанно **уже** в нише: **быстрый маршр - [#46](https://github.com/QueryaHub/OxyRoute/issues/46) — sub-routers ([`21.md`](../.github/ISSUE_BACKLOG/bodies/21.md)) - [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — multipart / urlencoded ([`22.md`](../.github/ISSUE_BACKLOG/bodies/22.md)) - [#48](https://github.com/QueryaHub/OxyRoute/issues/48) — **глобальные исключения** / `HTTPException` ([`23.md`](../.github/ISSUE_BACKLOG/bodies/23.md)) -- [#49](https://github.com/QueryaHub/OxyRoute/issues/49) — CORS ([`24.md`](../.github/ISSUE_BACKLOG/bodies/24.md)) **Протокол и безопасность** diff --git a/docs/handlers.md b/docs/handlers.md index 7b1bbb0..7b20b98 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -87,8 +87,11 @@ There is no **`register_exception_handler`** API yet; map custom exception types - Return **`None`**: continue with normal routing and body read. - Return **any other value**: use the same mapping as a route return value (`Response`, dict, `str`, etc.); the response is sent and **the route handler and body are skipped** (e.g. cheap CORS preflight on `OPTIONS` without consuming a `POST` body). +For a configurable **`allow_origins` / `allow_methods` / `allow_headers`** flow plus native merging of CORS headers into normal responses, use **`CORSConfig`** and **`apply_cors`** (see [cors.md](cors.md)). + ## See also - [Routing](routing.md) - [JWT](jwt.md) - [Dependencies](dependencies.md) +- [CORS](cors.md) diff --git a/docs/index.md b/docs/index.md index e4cce5c..71a5047 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,6 +41,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [RSGI and Granian](rsgi.md) | Why RSGI, `__rsgi__`, lifespan hooks, spec link | | [Routing](routing.md) | Path patterns, methods, 404s | | [Handlers](handlers.md) | Injected parameters, return types, JSON encoding | +| [CORS](cors.md) | `CORSConfig`, preflight, `apply_cors` | | [JWT](jwt.md) | `require_jwt`, HS* / RSA / EC PEM, `decode_jwt_hs` (HS* tests) | | [Dependencies](dependencies.md) | `Depends`, `dependencies=[...]`, `freeze` | | [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` | diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 70ae018..5bc4d4a 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -2,6 +2,7 @@ import oxyroute._oxyroute # noqa: F401 from oxyroute._oxyroute import decode_jwt_hs from oxyroute.app import App, Depends +from oxyroute.cors import CORSConfig, apply_cors from oxyroute.exceptions import HTTPException from oxyroute.response import Response from oxyroute.router import APIRouter @@ -9,8 +10,10 @@ __all__ = [ "APIRouter", "App", + "CORSConfig", "Depends", "HTTPException", + "apply_cors", "Response", "__version__", "decode_jwt_hs", diff --git a/oxyroute/app.py b/oxyroute/app.py index 9ef50f2..0001104 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -96,6 +96,14 @@ def set_middleware(self, handler: Callable[..., Any] | None) -> None: """ self._app.set_middleware(handler) + def set_cors(self, config: Any | None) -> None: + """ + Optional CORS object (e.g. :class:`oxyroute.cors.CORSConfig`) for merging response + headers. Used together with :func:`oxyroute.cors.apply_cors` or a custom + :meth:`set_middleware` for preflight. Pass ``None`` to disable. + """ + self._app.set_cors(config) + def get( self, path: str, diff --git a/oxyroute/cors.py b/oxyroute/cors.py new file mode 100644 index 0000000..b7ecc8d --- /dev/null +++ b/oxyroute/cors.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeAlias + +from oxyroute.app import App +from oxyroute.response import Response + +__all__ = ["CORSConfig", "apply_cors"] + +_Middleware: TypeAlias = Callable[[Any, Any], Response | None] +_HDR_SPLIT = re.compile(r"[\s,]+") + + +@dataclass +class CORSConfig: + """ + Declarative CORS settings. Register with :func:`apply_cors` (sets native ``set_cors`` and + preflight middleware) or assign via :meth:`oxyroute.app.App.set_cors` if you only need + 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). + """ + + allow_origins: list[str] = field(default_factory=lambda: ["*"]) + allow_methods: list[str] = field( + default_factory=lambda: ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + ) + allow_headers: list[str] = field(default_factory=lambda: ["*"]) + expose_headers: list[str] = field(default_factory=list) + allow_credentials: bool = False + max_age: int | None = 600 + + def __post_init__(self) -> None: + self._allow_methods_upper = {m.upper() for m in self.allow_methods} + + def _select_allow_origin(self, origin: str) -> str | None: + if not origin: + return None + if self.allow_credentials or "*" not in self.allow_origins: + if origin in self.allow_origins: + return origin + return None + if self.allow_origins == ["*"]: + return "*" + if origin in self.allow_origins: + return origin + return None + + def response_header_pairs(self, scope: Any) -> list[tuple[str, str]]: + """ + Pairs merged into the outgoing response (called from the native layer). For requests + without a permitted ``Origin``, returns an empty list. + """ + origin = str(scope.headers.get("origin", "") or "") + selected = self._select_allow_origin(origin) + if selected is None: + return [] + out: list[tuple[str, str]] = [("Access-Control-Allow-Origin", selected)] + if self.allow_credentials: + out.append(("Access-Control-Allow-Credentials", "true")) + if self.expose_headers: + out.append( + ( + "Access-Control-Expose-Headers", + ", ".join(self.expose_headers), + ) + ) + if selected != "*": + out.append(("Vary", "Origin")) + return out + + def preflight_response(self, scope: Any) -> Response | None: + """ + If this is a CORS preflight and the request is allowed, return ``204`` with preflight + headers; if it is a preflight but the origin is not allowed, return ``400``; otherwise + return ``None`` (not a preflight request). + """ + method = str(getattr(scope, "method", "") or "").upper() + if method != "OPTIONS": + return None + raw_acrm = str(scope.headers.get("access-control-request-method", "") or "") + if not raw_acrm: + return None + request_method = raw_acrm.strip().upper() + if request_method not in self._allow_methods_upper: + return Response(status=400, body="Disallowed CORS method", headers={}) + + origin = str(scope.headers.get("origin", "") or "") + selected = self._select_allow_origin(origin) + if selected is None: + return Response(status=400, body="CORS origin not allowed", headers={}) + + raw_ach = str(scope.headers.get("access-control-request-headers", "") or "") + acah = self._preflight_access_control_allow_headers_value(raw_ach) + if acah is None: + return Response(status=400, body="Disallowed CORS request headers", headers={}) + + h: dict[str, str] = { + "Access-Control-Allow-Origin": selected, + } + if self.allow_credentials: + h["Access-Control-Allow-Credentials"] = "true" + h["Access-Control-Allow-Methods"] = ", ".join(self.allow_methods) + h["Access-Control-Allow-Headers"] = acah + if self.max_age is not None: + h["Access-Control-Max-Age"] = str(self.max_age) + if selected != "*": + h["Vary"] = "Origin" + return Response(status=204, body=None, headers=h) + + def _preflight_access_control_allow_headers_value(self, raw_ach: str) -> str | None: + if self.allow_headers == ["*"] or (len(self.allow_headers) == 1 and self.allow_headers[0] == "*"): + return "*" + allow_lower = {x.lower() for x in self.allow_headers if x != "*"} + parts = [p for p in _HDR_SPLIT.split(raw_ach.strip()) if p] + if not parts: + return ", ".join(self.allow_headers) + for p in parts: + if p.lower() not in allow_lower: + return None + return ", ".join(parts) + + +def apply_cors( + app: App, + config: CORSConfig, + *, + chain: _Middleware | None = None, +) -> None: + """ + Register CORS: stores ``config`` for native response merging and installs preflight + handling via :meth:`oxyroute.app.App.set_middleware`. If you already use middleware for + other work, pass it as ``chain`` so it runs when the request is not a CORS preflight + (your handler runs after the CORS layer returns ``None`` for continuation). + + **Order:** this replaces ``set_middleware`` with an internal function. To combine with + another pre-route callback, use ``apply_cors(..., chain=your_middleware)``. + """ + app.set_cors(config) + + def _cors_middleware(scope: Any, protocol: Any) -> Response | None: + p = config.preflight_response(scope) + if p is not None: + return p + if chain is not None: + return chain(scope, protocol) + return None + + app.set_middleware(_cors_middleware) diff --git a/src/dispatch.rs b/src/dispatch.rs index f7c9ad5..4463f55 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -157,6 +157,11 @@ pub async fn run_rsgi( )) })?; let is_head = method == "HEAD"; + // `Py` must be cloned while the GIL is held (see PyO3 0.21+). + let cors_cfg = Python::with_gil(|_py| { + let s = state.read(); + s.cors.clone() + }); if (method == "GET" || method == "HEAD") && path == "/openapi.json" { let (inc, doc) = { let st = state.read(); @@ -204,6 +209,14 @@ pub async fn run_rsgi( return send_python_error(&protocol, &method, &path, e).await; } }; + let mapped = match Python::with_gil(|py| { + merge_cors_to_handler_map(py, &cors_cfg, scope.bind(py).clone(), mapped) + }) { + Ok(m) => m, + Err(e) => { + return send_python_error(&protocol, &method, &path, e).await; + } + }; return send_handler_map(&protocol, is_head, mapped).await; } } @@ -674,6 +687,14 @@ pub async fn run_rsgi( return send_python_error(&protocol, &method, &path, e).await; } }; + let mapped = match Python::with_gil(|py| { + merge_cors_to_handler_map(py, &cors_cfg, scope.bind(py).clone(), mapped) + }) { + Ok(m) => m, + Err(e) => { + return send_python_error(&protocol, &method, &path, e).await; + } + }; send_handler_map(&protocol, is_head, mapped).await } @@ -790,6 +811,63 @@ async fn send_handler_map( } } +fn merge_cors_to_handler_map( + py: Python<'_>, + cors: &Option>, + scope: Bound<'_, PyAny>, + mapped: HandlerMap, +) -> PyResult { + let Some(c) = cors else { + return Ok(mapped); + }; + let pairs: Vec<(String, String)> = c + .call_method1(py, "response_header_pairs", (&scope,))? + .extract(py)?; + if pairs.is_empty() { + return Ok(mapped); + } + Ok(merge_cors(mapped, &pairs)) +} + +fn merge_cors(mapped: HandlerMap, extra: &[(String, String)]) -> HandlerMap { + if extra.is_empty() { + return mapped; + } + match mapped { + HandlerMap::WithHeaders { + status, + body, + mut headers, + } => { + for (a, b) in extra { + headers.retain(|(k, _)| !k.eq_ignore_ascii_case(a)); + headers.push((a.clone(), b.clone())); + } + HandlerMap::WithHeaders { + status, + body, + headers, + } + } + HandlerMap::Simple { + status, + body, + content_type, + } => { + let mut headers = vec![("content-type".to_string(), content_type)]; + for (a, b) in extra { + headers.retain(|(k, _)| !k.eq_ignore_ascii_case(a)); + headers.push((a.clone(), b.clone())); + } + HandlerMap::WithHeaders { + status, + body, + headers, + } + } + } +} + fn is_oxyroute_response(_py: Python<'_>, b: &Bound<'_, PyAny>) -> PyResult { // `oxyroute.Response` (dataclass): not a plain dict; has instance attributes // `status` / `body` / `headers` (and optional `cookies`). Avoid `isinstance` / diff --git a/src/lib.rs b/src/lib.rs index e94c94e..7d83a6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -317,6 +317,13 @@ impl App { Ok(()) } + /// Optional Python CORS config with ``response_header_pairs(scope)`` (see ``oxyroute.cors``). + fn set_cors(&self, config: Option>) -> PyResult<()> { + let mut st = self.state.write(); + st.cors = config; + Ok(()) + } + fn handle_rsgi<'py>( this: PyRef<'py, Self>, py: Python<'py>, diff --git a/src/state.rs b/src/state.rs index 8e3417f..8016412 100644 --- a/src/state.rs +++ b/src/state.rs @@ -76,6 +76,8 @@ pub struct AppState { pub include_openapi: bool, /// Optional `(scope, protocol) ->` hook; return ``None`` to continue to routing (see `docs/handlers.md`). pub middleware: Option>, + /// Optional Python CORS config (e.g. :class:`oxyroute.cors.CORSConfig`) for response headers. + pub cors: Option>, } impl AppState { @@ -98,6 +100,7 @@ impl AppState { frozen: false, include_openapi: true, middleware: None, + cors: None, } } diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 0000000..05f7a90 --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,58 @@ +"""CORS preflight and cross-origin response headers (issue #49).""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App, CORSConfig, apply_cors + + +def test_cors_preflight_204_allows_post() -> None: + n = 0 + + app = App() + apply_cors(app, CORSConfig(allow_origins=["https://app.example"])) + + @app.post("/x") + def _x() -> str: + nonlocal n + n += 1 + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.request( + "OPTIONS", + "/x", + headers={ + "origin": "https://app.example", + "access-control-request-method": "POST", + }, + ) + assert r.status_code == 204, r.text + assert r.headers.get("access-control-allow-origin") == "https://app.example" + assert "POST" in (r.headers.get("access-control-allow-methods") or "") + assert n == 0 + + asyncio.run(_run()) + + +def test_cors_get_with_origin_merges_headers() -> None: + app = App() + apply_cors(app, CORSConfig(allow_origins=["https://a.example", "https://b.example"])) + + @app.get("/hi") + def _hi() -> str: + return "hello" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/hi", headers={"origin": "https://a.example"}) + assert r.status_code == 200 + assert r.text == "hello" + assert r.headers.get("access-control-allow-origin") == "https://a.example" + + asyncio.run(_run()) From 2c1107e8e95fc8cab60cbecde4a8ed13a507dbfd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 00:15:21 +0300 Subject: [PATCH 2/3] chore: add Makefile for local check (uv .venv, make test) - Ensure .venv with PY_BOOTSTRAP; UV_PYTHON points at .venv to avoid PEP 668 on /usr - One shot: sync, ruff, rustfmt+clippy, maturin develop --uv, pytest in temp dir --- Makefile | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a64efae --- /dev/null +++ b/Makefile @@ -0,0 +1,101 @@ +# Local full check in one command — same intent as .github/workflows/ci.yml +# Needs: https://github.com/astral-sh/uv, Rust (rustfmt, clippy), C toolchain (PyO3) +# +# All installs go into ./.venv (maturin develop --uv, uv run). Do NOT point package installs +# at /usr/bin/python3 on Debian/Ubuntu (PEP 668 “externally managed”). +# +# To choose which *base* interpreter uv venv uses when creating .venv (only if missing): +# make test PY_BOOTSTRAP=/usr/bin/python3.12 +# rm -rf .venv && make test PY_BOOTSTRAP=... — recreate venv with another base + +SHELL := bash +.SHELLFLAGS := -eu -o pipefail -c + +UV ?= uv +ROOT := $(abspath .) +export UV_PROJECT := $(ROOT) + +# Used only to *create* .venv; package installs use .venv, never the base interpreter. +PY_BOOTSTRAP ?= $(shell command -v python3) +VENV_PY := $(ROOT)/.venv/bin/python +export UV_PYTHON := $(VENV_PY) + +# Dev speed: make test MATURIN_FLAGS= | same as CI release build: (default) --release +MATURIN_FLAGS ?= --release +RUFF_PATHS := oxyroute tests examples + +.PHONY: help all test lint fix sync develop wheel install pytest build \ + _need-uv _need-python-bootstrap _ensure-venv + +help: + @echo "make test — venv + uv sync + ruff (check) + ruff format --check + rustfmt (check) + clippy +" + @echo " maturin develop --uv + pytest (isolated temp dir, like CI)" + @echo "make lint — venv + sync + Python/Rust checks only (no maturin, no tests)" + @echo "make fix — venv + sync + ruff format (write) + cargo fmt (not in make test)" + @echo "make build / develop — venv + sync + maturin develop --uv (no linters, no tests)" + @echo "make wheel — venv + build target/wheels/*.whl (packaging; no install to venv)" + @echo " MATURIN_FLAGS= — debug build (drop --release )" + @echo " PY_BOOTSTRAP= — base python3 to create .venv (default: which python3 )" + +all: test + +test: _need-uv _need-python-bootstrap _ensure-venv + $(UV) sync --frozen --extra dev + $(UV) run ruff check $(RUFF_PATHS) + $(UV) run ruff format --check $(RUFF_PATHS) + cargo fmt --all -- --check + cargo clippy --all-targets -- -D warnings + $(UV) run maturin develop --uv $(MATURIN_FLAGS) + @_d=$$(mktemp -d); trap 'rm -rf "$$_d"' EXIT; \ + (cd "$$_d" && UV_PROJECT="$(ROOT)" $(UV) run python -m pytest "$(ROOT)/tests" -v) + @echo OK + +lint: _need-uv _need-python-bootstrap _ensure-venv + $(UV) sync --frozen --extra dev + $(UV) run ruff check $(RUFF_PATHS) + $(UV) run ruff format --check $(RUFF_PATHS) + cargo fmt --all -- --check + cargo clippy --all-targets -- -D warnings + +fix: _need-uv _need-python-bootstrap _ensure-venv + $(UV) sync --frozen --extra dev + $(UV) run ruff format $(RUFF_PATHS) + cargo fmt --all + +sync: _need-uv _need-python-bootstrap _ensure-venv + $(UV) sync --frozen --extra dev + +develop: _need-uv sync + $(UV) run maturin develop --uv $(MATURIN_FLAGS) + +# Release wheel in target/wheels/ (as in CI “build” job); does not install to venv +wheel: _need-uv sync + rm -f target/wheels/oxyroute-*.whl + $(UV) run maturin build $(MATURIN_FLAGS) + +# Historical alias: same as develop +build: develop +install: develop + +pytest: _need-uv _need-python-bootstrap _ensure-venv + $(UV) sync --frozen --extra dev + @_d=$$(mktemp -d); trap 'rm -rf "$$_d"' EXIT; \ + (cd "$$_d" && UV_PROJECT="$(ROOT)" $(UV) run python -m pytest "$(ROOT)/tests" -v) + +_need-uv: + @command -v $(UV) >/dev/null 2>&1 || { \ + echo "error: '$(UV)' not on PATH. Install: https://docs.astral.sh/uv/"; \ + exit 1; \ + } + +_need-python-bootstrap: + @test -n "$(PY_BOOTSTRAP)" || { \ + echo "error: no python3 on PATH (set PY_BOOTSTRAP=/path/to/python)" >&2; \ + exit 1; \ + } + +# Create .venv if missing. First run must not set UV_PYTHON to a non-existent .venv binary +# or uv venv can get confused; clear it for this one line only. +_ensure-venv: _need-uv _need-python-bootstrap + @if [ -x "$(VENV_PY)" ]; then exit 0; fi + @env -u UV_PYTHON $(UV) venv --python "$(PY_BOOTSTRAP)" "$(ROOT)/.venv" From 22a6bb40994259e609f38a01b077ecfca271e018 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 00:15:23 +0300 Subject: [PATCH 3/3] chore: document make test + maintainer pre-push; ruff on cors/__all__ - AGENTS.md and git-workflow: prefer make test, human runs before push - oxyroute: RUF022 __all__ order, ruff format cors.py --- .cursor/rules/git-workflow.mdc | 6 +++--- AGENTS.md | 4 ++++ oxyroute/__init__.py | 2 +- oxyroute/cors.py | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc index ae6a770..ba38d27 100644 --- a/.cursor/rules/git-workflow.mdc +++ b/.cursor/rules/git-workflow.mdc @@ -23,9 +23,9 @@ alwaysApply: true 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). + - **One-shot (recommended):** `make test` from the repo root — runs `uv` sync, ruff, `cargo fmt`/`clippy`, `maturin develop --uv`, and pytest in a temp directory (see [`Makefile`](Makefile)). + - **By hand (same as CI / `make test`):** `uv run ruff check` / `ruff format --check` on `oxyroute`, `tests`, `examples`; `cargo fmt --all -- --check`; `cargo clippy --all-targets -- -D warnings`; then build + test as in CI. + - **Project owner:** runs `make test` (or equivalent) before push themselves. If something fails locally or in CI, they will report it — then fix the reported problem; do not assume a green run on the agent side unless you actually executed the checks in this environment. 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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e2f958e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# Notes for AI agents (OxyRoute) + +- **Git / branches / PRs / validation** — [`.cursor/rules/git-workflow.mdc`](.cursor/rules/git-workflow.mdc) (`alwaysApply`). +- **Local full check before push:** from repo root run `make test` (see [`Makefile`](Makefile)). Maintainers run this themselves before pushing; if a check fails, they will report it — then address that failure, rather than assuming everything passed without a run in your session. diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 5bc4d4a..1bacc58 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -13,9 +13,9 @@ "CORSConfig", "Depends", "HTTPException", - "apply_cors", "Response", "__version__", + "apply_cors", "decode_jwt_hs", ] __version__ = "0.1.0" diff --git a/oxyroute/cors.py b/oxyroute/cors.py index b7ecc8d..a77d200 100644 --- a/oxyroute/cors.py +++ b/oxyroute/cors.py @@ -113,7 +113,9 @@ def preflight_response(self, scope: Any) -> Response | None: return Response(status=204, body=None, headers=h) def _preflight_access_control_allow_headers_value(self, raw_ach: str) -> str | None: - if self.allow_headers == ["*"] or (len(self.allow_headers) == 1 and self.allow_headers[0] == "*"): + if self.allow_headers == ["*"] or ( + len(self.allow_headers) == 1 and self.allow_headers[0] == "*" + ): return "*" allow_lower = {x.lower() for x in self.allow_headers if x != "*"} parts = [p for p in _HDR_SPLIT.split(raw_ach.strip()) if p]