Skip to content

Commit bdc5647

Browse files
Merge pull request #62 from QueryaHub/issue-49-cors
Issue 49 cors
2 parents 8edd00d + 22a6bb4 commit bdc5647

16 files changed

Lines changed: 487 additions & 9 deletions

File tree

.cursor/rules/git-workflow.mdc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ alwaysApply: true
2323
4. **Implement** in the branch (Rust/Python/docs as needed).
2424

2525
5. **Validate before commit/push** (match CI in [`.github/workflows/ci.yml`](.github/workflows/ci.yml))
26-
- Python: `uv run ruff check oxyroute tests examples` and `uv run ruff format --check oxyroute tests examples` (or format then check).
27-
- Rust: `cargo fmt --all -- --check` and `cargo clippy --all-targets -- -D warnings`
28-
- Tests: `uv run pytest` (or the same isolated pattern as in `docs/development.md` if testing the installed wheel).
26+
- **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)).
27+
- **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.
28+
- **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.
2929

3030
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.
3131

.github/ISSUE_BACKLOG/PRIORITIES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
1515
|---|------|-----------|
1616
| 8 | [08.md](bodies/08.md) | **JWK / JWKS**[GitHub #8](https://github.com/QueryaHub/OxyRoute/issues/8). |
1717
| 9 | [09.md](bodies/09.md) | **OpenAPI depth** (optional) — `$ref` / `$defs`. |
18-
| 24 | [24.md](bodies/24.md) | **CORS helper**[GitHub #49](https://github.com/QueryaHub/OxyRoute/issues/49). |
1918
| 28 | [28.md](bodies/28.md) | **CSRF** (optional) — [GitHub #53](https://github.com/QueryaHub/OxyRoute/issues/53). |
2019
| 29 | [29.md](bodies/29.md) | **Security headers preset**[GitHub #54](https://github.com/QueryaHub/OxyRoute/issues/54). |
2120

@@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
3635
- **Form bodies:** 22 / [#47](https://github.com/QueryaHub/OxyRoute/issues/47)`read_form_body`, `form` / `files` kwargs, `docs/handlers.md`
3736
- **HTTPException:** 23 / [#48](https://github.com/QueryaHub/OxyRoute/issues/48)`oxyroute.exceptions`, `docs/handlers.md` (per-type `register_exception_handler` not in scope)
3837
- **Sub-routers:** 21 / [#46](https://github.com/QueryaHub/OxyRoute/issues/46)`APIRouter`, `include_router`, `docs/routing.md`
38+
- **CORS:** 24 / [#49](https://github.com/QueryaHub/OxyRoute/issues/49)`CORSConfig`, `apply_cors`, `set_cors`, `docs/cors.md`
3939

4040
## Roadmap phasing (summary)
4141

4242
1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible).
43-
2. **P1:** 8, 49, 53, 54; **#48** / **#46** done; 9 as polish.
43+
2. **P1:** 8, 53, 54; **#48** / **#46** / **#49 (CORS)** done; 9 as polish.
4444
3. **Research:** 50, 51, 52 — as capacity allows.
4545

4646
[← Back to README](README.md)

.github/ISSUE_BACKLOG/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This directory holds **20 + N issue bodies** ([bodies/](bodies/)) and a [PRIORIT
44

55
## Status and GitHub (living backlog)
66

7-
- **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**).
7+
- **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**).
88
- **Closed milestone [v0.3.0](https://github.com/QueryaHub/OxyRoute/milestone/2):** unused name; work consolidated under **v0.2.0**.
99
- Optional OpenAPI depth (`$ref` / `$defs`): [09.md](bodies/09.md); issue #9 is closed — open a new issue if you pick this up.
1010
- **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`.

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Notes for AI agents (OxyRoute)
2+
3+
- **Git / branches / PRs / validation**[`.cursor/rules/git-workflow.mdc`](.cursor/rules/git-workflow.mdc) (`alwaysApply`).
4+
- **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.

Makefile

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Local full check in one command — same intent as .github/workflows/ci.yml
2+
# Needs: https://github.com/astral-sh/uv, Rust (rustfmt, clippy), C toolchain (PyO3)
3+
#
4+
# All installs go into ./.venv (maturin develop --uv, uv run). Do NOT point package installs
5+
# at /usr/bin/python3 on Debian/Ubuntu (PEP 668 “externally managed”).
6+
#
7+
# To choose which *base* interpreter uv venv uses when creating .venv (only if missing):
8+
# make test PY_BOOTSTRAP=/usr/bin/python3.12
9+
# rm -rf .venv && make test PY_BOOTSTRAP=... — recreate venv with another base
10+
11+
SHELL := bash
12+
.SHELLFLAGS := -eu -o pipefail -c
13+
14+
UV ?= uv
15+
ROOT := $(abspath .)
16+
export UV_PROJECT := $(ROOT)
17+
18+
# Used only to *create* .venv; package installs use .venv, never the base interpreter.
19+
PY_BOOTSTRAP ?= $(shell command -v python3)
20+
VENV_PY := $(ROOT)/.venv/bin/python
21+
export UV_PYTHON := $(VENV_PY)
22+
23+
# Dev speed: make test MATURIN_FLAGS= | same as CI release build: (default) --release
24+
MATURIN_FLAGS ?= --release
25+
RUFF_PATHS := oxyroute tests examples
26+
27+
.PHONY: help all test lint fix sync develop wheel install pytest build \
28+
_need-uv _need-python-bootstrap _ensure-venv
29+
30+
help:
31+
@echo "make test — venv + uv sync + ruff (check) + ruff format --check + rustfmt (check) + clippy +"
32+
@echo " maturin develop --uv + pytest (isolated temp dir, like CI)"
33+
@echo "make lint — venv + sync + Python/Rust checks only (no maturin, no tests)"
34+
@echo "make fix — venv + sync + ruff format (write) + cargo fmt (not in make test)"
35+
@echo "make build / develop — venv + sync + maturin develop --uv (no linters, no tests)"
36+
@echo "make wheel — venv + build target/wheels/*.whl (packaging; no install to venv)"
37+
@echo " MATURIN_FLAGS= — debug build (drop --release )"
38+
@echo " PY_BOOTSTRAP= — base python3 to create .venv (default: which python3 )"
39+
40+
all: test
41+
42+
test: _need-uv _need-python-bootstrap _ensure-venv
43+
$(UV) sync --frozen --extra dev
44+
$(UV) run ruff check $(RUFF_PATHS)
45+
$(UV) run ruff format --check $(RUFF_PATHS)
46+
cargo fmt --all -- --check
47+
cargo clippy --all-targets -- -D warnings
48+
$(UV) run maturin develop --uv $(MATURIN_FLAGS)
49+
@_d=$$(mktemp -d); trap 'rm -rf "$$_d"' EXIT; \
50+
(cd "$$_d" && UV_PROJECT="$(ROOT)" $(UV) run python -m pytest "$(ROOT)/tests" -v)
51+
@echo OK
52+
53+
lint: _need-uv _need-python-bootstrap _ensure-venv
54+
$(UV) sync --frozen --extra dev
55+
$(UV) run ruff check $(RUFF_PATHS)
56+
$(UV) run ruff format --check $(RUFF_PATHS)
57+
cargo fmt --all -- --check
58+
cargo clippy --all-targets -- -D warnings
59+
60+
fix: _need-uv _need-python-bootstrap _ensure-venv
61+
$(UV) sync --frozen --extra dev
62+
$(UV) run ruff format $(RUFF_PATHS)
63+
cargo fmt --all
64+
65+
sync: _need-uv _need-python-bootstrap _ensure-venv
66+
$(UV) sync --frozen --extra dev
67+
68+
develop: _need-uv sync
69+
$(UV) run maturin develop --uv $(MATURIN_FLAGS)
70+
71+
# Release wheel in target/wheels/ (as in CI “build” job); does not install to venv
72+
wheel: _need-uv sync
73+
rm -f target/wheels/oxyroute-*.whl
74+
$(UV) run maturin build $(MATURIN_FLAGS)
75+
76+
# Historical alias: same as develop
77+
build: develop
78+
install: develop
79+
80+
pytest: _need-uv _need-python-bootstrap _ensure-venv
81+
$(UV) sync --frozen --extra dev
82+
@_d=$$(mktemp -d); trap 'rm -rf "$$_d"' EXIT; \
83+
(cd "$$_d" && UV_PROJECT="$(ROOT)" $(UV) run python -m pytest "$(ROOT)/tests" -v)
84+
85+
_need-uv:
86+
@command -v $(UV) >/dev/null 2>&1 || { \
87+
echo "error: '$(UV)' not on PATH. Install: https://docs.astral.sh/uv/"; \
88+
exit 1; \
89+
}
90+
91+
_need-python-bootstrap:
92+
@test -n "$(PY_BOOTSTRAP)" || { \
93+
echo "error: no python3 on PATH (set PY_BOOTSTRAP=/path/to/python)" >&2; \
94+
exit 1; \
95+
}
96+
97+
# Create .venv if missing. First run must not set UV_PYTHON to a non-existent .venv binary
98+
# or uv venv can get confused; clear it for this one line only.
99+
_ensure-venv: _need-uv _need-python-bootstrap
100+
@if [ -x "$(VENV_PY)" ]; then exit 0; fi
101+
@env -u UV_PYTHON $(UV) venv --python "$(PY_BOOTSTRAP)" "$(ROOT)/.venv"

docs/cors.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# CORS
2+
3+
[← Documentation index](index.md)
4+
5+
Cross-origin resource sharing is supported in two layers:
6+
7+
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.
8+
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.
9+
10+
## Basic usage
11+
12+
```python
13+
from oxyroute import App, CORSConfig, apply_cors
14+
15+
app = App()
16+
apply_cors(
17+
app,
18+
CORSConfig(
19+
allow_origins=["https://my.frontend.example"],
20+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
21+
allow_headers=["*"],
22+
),
23+
)
24+
25+
@app.get("/api/x")
26+
def x() -> dict:
27+
return {"ok": True}
28+
```
29+
30+
## Combining with another middleware
31+
32+
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=`**:
33+
34+
```python
35+
def my_mw(scope, protocol):
36+
# runs only when apply_cors did not return a preflight response
37+
return None
38+
39+
apply_cors(app, config, chain=my_mw)
40+
```
41+
42+
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)`.
43+
44+
## Configuration fields
45+
46+
| Field | Role |
47+
|--------|------|
48+
| `allow_origins` | List of allowed `Origin` values, or `["*"]` when not using credentials. |
49+
| `allow_methods` | HTTP methods allowed in preflight and echoed in `Access-Control-Allow-Methods`. |
50+
| `allow_headers` | `["*"]` or a list of permitted request header names for preflight. |
51+
| `expose_headers` | Optional list; sent as `Access-Control-Expose-Headers` on real responses. |
52+
| `allow_credentials` | If true, `Access-Control-Allow-Credentials: true` and `*` cannot be used as the origin. |
53+
| `max_age` | Seconds for `Access-Control-Max-Age` on preflight, or `None` to omit. |
54+
55+
## See also
56+
57+
- [Handlers](handlers.md)`set_middleware` and return mapping
58+
- [RSGI and Granian](rsgi.md)

docs/feature.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
| Тема | Зазор | Комментарий |
5858
|------|--------|-------------|
59-
| **CORS** | Ручной | В доках упомянут preflight через `set_middleware`; нет готового `CORSMiddleware` с настройками. |
59+
| **CORS** | `CORSConfig` + `apply_cors` / `set_cors` | Preflight и заголовки на ответах; см. [cors.md](cors.md). |
6060
| **CSRF** | Нет | Для cookie-сессий и форм часто нужны токены. |
6161
| **Rate limiting** | Нет | |
6262
| **Security headers** (HSTS, CSP, …) | Ручные заголовки | Нет пресетов. |
@@ -112,7 +112,7 @@ OxyRoute осознанно **уже** в нише: **быстрый маршр
112112
3. **Перф роутера** [#4](https://github.com/QueryaHub/OxyRoute/issues/4) — при росте нагрузки.
113113
4. **Sub-routers или префиксы** — резко повышают пригодность для крупных приложений.
114114
5. **Multipart + form body** — если не только JSON API.
115-
6. **CORS / exception handlers** — быстрые победы на Python-стороне без ломки RSGI.
115+
6. **Exception handlers (глобальные)** — быстрые победы на Python-стороне без ломки RSGI. **CORS** — см. [cors.md](cors.md) / [#49](https://github.com/QueryaHub/OxyRoute/issues/49).
116116

117117
## Связанные GitHub-issues (milestone v0.2.0)
118118

@@ -121,7 +121,6 @@ OxyRoute осознанно **уже** в нише: **быстрый маршр
121121
- [#46](https://github.com/QueryaHub/OxyRoute/issues/46) — sub-routers ([`21.md`](../.github/ISSUE_BACKLOG/bodies/21.md))
122122
- [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — multipart / urlencoded ([`22.md`](../.github/ISSUE_BACKLOG/bodies/22.md))
123123
- [#48](https://github.com/QueryaHub/OxyRoute/issues/48)**глобальные исключения** / `HTTPException` ([`23.md`](../.github/ISSUE_BACKLOG/bodies/23.md))
124-
- [#49](https://github.com/QueryaHub/OxyRoute/issues/49) — CORS ([`24.md`](../.github/ISSUE_BACKLOG/bodies/24.md))
125124

126125
**Протокол и безопасность**
127126

docs/handlers.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ There is no **`register_exception_handler`** API yet; map custom exception types
8787
- Return **`None`**: continue with normal routing and body read.
8888
- 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).
8989

90+
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)).
91+
9092
## See also
9193

9294
- [Routing](routing.md)
9395
- [JWT](jwt.md)
9496
- [Dependencies](dependencies.md)
97+
- [CORS](cors.md)

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod
4141
| [RSGI and Granian](rsgi.md) | Why RSGI, `__rsgi__`, lifespan hooks, spec link |
4242
| [Routing](routing.md) | Path patterns, methods, 404s |
4343
| [Handlers](handlers.md) | Injected parameters, return types, JSON encoding |
44+
| [CORS](cors.md) | `CORSConfig`, preflight, `apply_cors` |
4445
| [JWT](jwt.md) | `require_jwt`, HS* / RSA / EC PEM, `decode_jwt_hs` (HS* tests) |
4546
| [Dependencies](dependencies.md) | `Depends`, `dependencies=[...]`, `freeze` |
4647
| [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` |

oxyroute/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22
import oxyroute._oxyroute # noqa: F401
33
from oxyroute._oxyroute import decode_jwt_hs
44
from oxyroute.app import App, Depends
5+
from oxyroute.cors import CORSConfig, apply_cors
56
from oxyroute.exceptions import HTTPException
67
from oxyroute.response import Response
78
from oxyroute.router import APIRouter
89

910
__all__ = [
1011
"APIRouter",
1112
"App",
13+
"CORSConfig",
1214
"Depends",
1315
"HTTPException",
1416
"Response",
1517
"__version__",
18+
"apply_cors",
1619
"decode_jwt_hs",
1720
]
1821
__version__ = "0.1.0"

0 commit comments

Comments
 (0)