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
6 changes: 3 additions & 3 deletions .cursor/rules/git-workflow.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions .github/ISSUE_BACKLOG/PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Expand All @@ -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)
2 changes: 1 addition & 1 deletion .github/ISSUE_BACKLOG/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
101 changes: 101 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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"
58 changes: 58 additions & 0 deletions docs/cors.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 2 additions & 3 deletions docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, …) | Ручные заголовки | Нет пресетов. |
Expand Down Expand Up @@ -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)

Expand All @@ -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))

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

Expand Down
3 changes: 3 additions & 0 deletions docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
3 changes: 3 additions & 0 deletions oxyroute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@
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

__all__ = [
"APIRouter",
"App",
"CORSConfig",
"Depends",
"HTTPException",
"Response",
"__version__",
"apply_cors",
"decode_jwt_hs",
]
__version__ = "0.1.0"
8 changes: 8 additions & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading