From b691e7f3b0268cbb199bb589eb57598f068ac587 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:22:14 +0530 Subject: [PATCH 01/11] docs: add design spec for additive API simplification layer Covers add_middleware(cls, **kwargs) without functools.partial, enable_cors/csrf/sessions/rate_limit/gzip/trusted_hosts convenience methods, a top-level export audit, and a README/example refresh. Purely additive -- no existing API changes. --- ...9-api-simplification-sugar-layer-design.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-19-api-simplification-sugar-layer-design.md diff --git a/docs/superpowers/specs/2026-09-19-api-simplification-sugar-layer-design.md b/docs/superpowers/specs/2026-09-19-api-simplification-sugar-layer-design.md new file mode 100644 index 0000000..1871cd0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-19-api-simplification-sugar-layer-design.md @@ -0,0 +1,224 @@ +# API Simplification: Additive Sugar Layer — Design + +## Context + +Velocix's core request/response/DI pipeline is already FastAPI-shaped +(`Query`/`Header`/`Cookie`/`Body`/`Form`/`File` markers, `Depends`, msgspec +Structs, `@app.get`/`@app.post` decorators). The friction isn't in that core +API — it's in everything around it: wiring up middleware requires +`functools.partial`, common security features require knowing exact class +names and import paths across three different `velocix.security.*` modules, +and some useful types aren't exported from the top-level `velocix` package. + +This project is purely additive. Nothing existing is renamed, removed, or +changed in behavior — confirmed duplicates found during investigation +(`velocix.core.middleware.CORSMiddleware` vs `velocix.security.cors.CORSMiddleware`; +three overlapping OpenAPI/docs pipelines in `velocix/openapi/`; +`velocix.validation.Struct` being a bare `msgspec.Struct` alias) are +explicitly **out of scope** and left untouched. The goal is to make the +common path shorter and more discoverable without touching what already +works, so no existing app can break. + +## Goals + +- Registering middleware with configuration should not require importing + `functools.partial`. +- The most common security/middleware setups (CORS, CSRF, sessions, rate + limiting, gzip, trusted hosts) should be a single method call on `Velocix` + with no need to know the underlying class name or its module path. +- Everything a typical app needs should import from `velocix` directly. +- The README and shipped example should demonstrate this simplified path, + since they're a new dev's first impression. + +## Non-goals + +- No changes to the DI/marker system (`Query`, `Depends`, etc.) — it already + matches FastAPI's ergonomics. +- No deletion or deprecation of any existing class, module, or import path. +- No new top-level entry point / app class — `Velocix` remains the only + front door. + +## Component 1: `add_middleware` accepts config directly + +**File:** `velocix/core/app.py` + +Current signature only accepts a bare class or an already-built callable +(typically produced with `functools.partial`): + +```python +def add_middleware(self, middleware_class: type[BaseMiddleware] | Callable[..., Any]) -> None: + self._middleware_stack.append(middleware_class) +``` + +New signature accepts optional `*args, **kwargs` and wraps with +`functools.partial` internally when either is given: + +```python +def add_middleware( + self, + middleware_class: type[BaseMiddleware] | Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> None: + """Add middleware to the stack. + + Extra positional/keyword args are bound to the middleware's constructor + (after `app`), so config no longer requires `functools.partial`: + + app.add_middleware(CORSMiddleware, allow_origins=["https://example.com"]) + + A bare class or an already-partial'd callable still works unchanged. + """ + if args or kwargs: + middleware_class = partial(middleware_class, *args, **kwargs) + self._middleware_stack.append(middleware_class) +``` + +Requires adding `from functools import partial` to `velocix/core/app.py`'s +imports (not currently imported there — only `velocix/core/middleware.py` +and `velocix/__init__.py` import it today). + +`build_middleware_stack` in `velocix/core/middleware.py` needs no change — +it already calls `middleware_class(app)`, and a `partial` object called with +one positional arg behaves identically whether built by the caller or by +`add_middleware` itself. + +Backward compatible: `add_middleware(Cls)` and +`add_middleware(partial(Cls, key=val))` both still work exactly as today. + +## Component 2: `enable_*` convenience methods on `Velocix` + +**File:** `velocix/core/app.py` + +Six thin wrapper methods, each importing its middleware lazily (matching +the existing lazy-import style already used in `_setup_docs_routes`) and +calling the new kwargs-aware `add_middleware`. Modeled directly on the +logic that already exists in `create_app()` (`velocix/__init__.py:94-114`) +for CORS and rate limiting. + +```python +def enable_cors( + self, + allow_origins: list[str] | None = None, + allow_methods: list[str] | None = None, + allow_headers: list[str] | None = None, + allow_credentials: bool = False, + max_age: int = 600, + allow_origin_regex: str | None = None, +) -> None: + """Add CORS support. Wraps velocix.security.cors.CORSMiddleware.""" + from velocix.security.cors import CORSMiddleware + self.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_methods=allow_methods, + allow_headers=allow_headers, + allow_credentials=allow_credentials, + max_age=max_age, + allow_origin_regex=allow_origin_regex, + ) + +def enable_csrf(self, secret_key: str, **kwargs: Any) -> None: + """Add CSRF double-submit-cookie protection. Wraps + velocix.security.csrf.CSRFMiddleware. Extra kwargs (cookie_name, + exempt_paths, etc.) pass through.""" + from velocix.security.csrf import CSRFMiddleware + self.add_middleware(CSRFMiddleware, secret_key=secret_key, **kwargs) + +def enable_sessions(self, secret_key: str, **kwargs: Any) -> None: + """Add signed cookie sessions. Wraps + velocix.core.middleware.SessionMiddleware. Extra kwargs (max_age, + same_site, etc.) pass through.""" + from velocix.core.middleware import SessionMiddleware + self.add_middleware(SessionMiddleware, secret_key=secret_key, **kwargs) + +def enable_rate_limit(self, limit: int = 100, window: float = 60) -> None: + """Add a global rate limit. Wraps + velocix.security.ratelimit.RateLimitMiddleware with a + ProductionRateLimiter configured for a single global sliding window.""" + from velocix.security.ratelimit import ProductionRateLimiter, RateLimitMiddleware + limiter = ProductionRateLimiter() + limiter.set_global_window(limit=limit, window_size=window) + self.add_middleware(RateLimitMiddleware, limiter=limiter) + +def enable_gzip(self, minimum_size: int = 500, compresslevel: int = 9) -> None: + """Add gzip response compression. Wraps + velocix.core.middleware.GZipMiddleware.""" + from velocix.core.middleware import GZipMiddleware + self.add_middleware(GZipMiddleware, minimum_size=minimum_size, compresslevel=compresslevel) + +def enable_trusted_hosts(self, allowed_hosts: list[str]) -> None: + """Restrict accepted Host headers. Wraps + velocix.core.middleware.TrustedHostMiddleware.""" + from velocix.core.middleware import TrustedHostMiddleware + self.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) +``` + +Each method's kwargs/defaults mirror the wrapped middleware's own +constructor exactly — no new defaults are invented. `enable_csrf` and +`enable_sessions` take `**kwargs` (rather than spelling out every param) +because both middlewares have several optional settings and the only +consistently-required one is `secret_key`; `enable_cors` spells out params +explicitly since callers commonly need to see the options (origins, +methods, headers) to fill in. + +No new `__slots__` entries needed — these methods only call +`self.add_middleware`, they don't add app state. + +## Component 3: Top-level export audit + +**File:** `velocix/__init__.py` + +Add imports + `__all__` entries for names that exist today but aren't +reachable from `velocix` directly: + +- `TrustedHostMiddleware`, `GZipMiddleware`, `BaseHTTPMiddleware` (from + `velocix.core.middleware`) +- `CSRFMiddleware` (from `velocix.security.csrf`) +- `Body` (from `velocix.core.params`, joining the already-exported + `Query`/`Header`/`Cookie`/`Form`/`File`) +- `Struct` — re-exported directly as `msgspec.Struct` (`from msgspec import + Struct`), so `from velocix import Struct` works without ever touching + `velocix.validation` or a separate `msgspec` import. `velocix.validation.Struct` + keeps working unchanged (it's the same object). + +## Component 4: README + example refresh + +**Files:** `README.md`, `examples/openapi_example.py` + +- README quickstart gains a short "Middleware & security" section showing + `app.enable_cors(...)` instead of the raw `add_middleware`/`partial` + pattern. +- `examples/openapi_example.py` currently imports `from velocix.validation + import Struct` and ends by calling `enable_auto_docs(...)` — redundant + with the automatic docs `Velocix()` already sets up in `__init__` + (confirmed in `app.py:942-1099`). Update the example to import `Struct` + from `velocix` directly and drop the `enable_auto_docs` call entirely, so + the shipped example demonstrates the already-automatic behavior instead of + the legacy manual path. (The legacy pipeline itself is untouched per + Non-goals — this only changes what the example teaches.) + +## Testing + +One test file, following the existing per-feature convention +(`tests/test_security_*.py`): + +**`tests/test_app_sugar.py`** +- `add_middleware(Cls, **kwargs)` registers a middleware that receives + those kwargs (assert via a middleware that records its init args). +- `add_middleware(Cls)` and `add_middleware(partial(Cls, ...))` still work + (regression coverage for backward compatibility). +- Each `enable_*` method, via `TestClient`: `enable_cors` produces the + right `access-control-allow-origin` header; `enable_csrf` blocks an + unauthenticated POST with 403 and passes a GET; `enable_sessions` round-trips + a session value; `enable_rate_limit` returns 429 after the configured + limit; `enable_gzip` compresses a large response; `enable_trusted_hosts` + rejects an unlisted Host header. +- Top-level imports resolve: `from velocix import (TrustedHostMiddleware, + GZipMiddleware, BaseHTTPMiddleware, CSRFMiddleware, Body, Struct)` and + `Struct is msgspec.Struct`. + +## Compatibility + +Purely additive — every existing import, class, and method signature keeps +working unchanged. No app using today's API needs to change anything. From e6fe04a8b20fe8dab91eaae8b3600b357a3d5d41 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:22:14 +0530 Subject: [PATCH 02/11] docs: add implementation plan for additive API simplification layer Four TDD tasks: add_middleware kwargs support, six enable_* convenience methods, top-level export audit, README/example refresh. --- ...26-09-19-api-simplification-sugar-layer.md | 772 ++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-19-api-simplification-sugar-layer.md diff --git a/docs/superpowers/plans/2026-09-19-api-simplification-sugar-layer.md b/docs/superpowers/plans/2026-09-19-api-simplification-sugar-layer.md new file mode 100644 index 0000000..674c2d1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-api-simplification-sugar-layer.md @@ -0,0 +1,772 @@ +# API Simplification: Additive Sugar Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make common Velocix setup (middleware config, security features, imports) shorter and more discoverable without changing or removing anything that exists today. + +**Architecture:** Four additive changes to `velocix/core/app.py` and `velocix/__init__.py`: (1) `add_middleware` accepts `*args`/`**kwargs` directly instead of requiring `functools.partial`, (2) six `enable_*` convenience methods on `Velocix` wrap the existing middleware classes with `add_middleware`, (3) a top-level export audit adds currently-missing names to `velocix/__init__.py`, (4) the README and shipped example are updated to demonstrate the simplified path. No existing class, function, or method signature changes behavior for existing callers. + +**Tech Stack:** Python 3.11+, pytest, existing Velocix test conventions (`asyncio.run`-wrapped scenario functions, `TestClient`). + +**Spec:** `docs/superpowers/specs/2026-09-19-api-simplification-sugar-layer-design.md` + +## Global Constraints + +- Purely additive: no existing import, class, method signature, or behavior may change for callers using today's API. +- No new top-level entry point — `Velocix` remains the only app class. +- No changes to the DI/marker system (`Query`, `Header`, `Cookie`, `Body`, `Form`, `File`, `Depends`). +- No deletion, deprecation, or modification of the duplicate `CORSMiddleware`, the redundant OpenAPI/docs pipelines, or the `velocix.validation.Struct` alias — explicitly out of scope. +- Every `enable_*` method's parameters and defaults must match the wrapped middleware's own constructor exactly — no new defaults invented. + +**Environment setup:** this repo's dependencies (including `nh3`, used by +`velocix.security.input_sanitization`) are not installed in a bare +`python3`/`pip` on this machine — running `pytest` without setup fails +collection with `ModuleNotFoundError: No module named 'nh3'`. Before Task 1, +create/activate a venv and run `pip install -r requirements.txt -e .` from +the repo root; run all `pytest`/`ruff`/`mypy` commands in this plan inside +that environment. + +--- + +### Task 1: `add_middleware` accepts config directly + +**Files:** +- Modify: `velocix/core/app.py` (imports block ~line 1-24, `add_middleware` method ~line 373-375) +- Test: `tests/test_app_sugar.py` (new file) + +**Interfaces:** +- Produces: `Velocix.add_middleware(middleware_class, *args, **kwargs)` — when `args` or `kwargs` are given, wraps `middleware_class` in `functools.partial(middleware_class, *args, **kwargs)` before appending to `self._middleware_stack`. Bare-class and pre-built-`partial` calls behave exactly as before. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_app_sugar.py`: + +```python +"""Tests for the additive API-simplification sugar layer: add_middleware +kwargs support, enable_* convenience methods, and top-level exports. +""" + +import asyncio +from functools import partial + +from velocix import TestClient, Velocix +from velocix.core.middleware import BaseMiddleware + + +def _run(coro): + return asyncio.run(coro) + + +class _RecordingMiddleware(BaseMiddleware): + """Records the kwargs it was constructed with.""" + + last_kwargs: dict = {} + + def __init__(self, app, **kwargs): + super().__init__(app) + _RecordingMiddleware.last_kwargs = kwargs + + async def __call__(self, request): + return await self.app(request) + + +def test_add_middleware_accepts_kwargs_without_partial(): + app = Velocix() + + @app.get("/ping") + async def ping(): + return {"ok": True} + + app.add_middleware(_RecordingMiddleware, greeting="hi") + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + + _run(scenario()) + assert _RecordingMiddleware.last_kwargs == {"greeting": "hi"} + + +def test_add_middleware_bare_class_still_works(): + app = Velocix() + + @app.get("/ping") + async def ping(): + return {"ok": True} + + app.add_middleware(_RecordingMiddleware) + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + + _run(scenario()) + assert _RecordingMiddleware.last_kwargs == {} + + +def test_add_middleware_with_partial_still_works(): + app = Velocix() + + @app.get("/ping") + async def ping(): + return {"ok": True} + + app.add_middleware(partial(_RecordingMiddleware, greeting="via-partial")) + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + + _run(scenario()) + assert _RecordingMiddleware.last_kwargs == {"greeting": "via-partial"} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_app_sugar.py -v` +Expected: `test_add_middleware_accepts_kwargs_without_partial` FAILS with +`TypeError: add_middleware() takes 2 positional arguments but 3 were given` +(or similar — the current `add_middleware(self, middleware_class)` doesn't +accept `greeting="hi"`). The other two tests should already PASS (bare +class and `partial` already work today) — that's expected, they're +regression coverage for this task, not new behavior. + +- [ ] **Step 3: Implement `add_middleware` kwargs support** + +In `velocix/core/app.py`, add the import (insert alphabetically — after +`from collections.abc import ...`, before `from typing import Any`): + +```python +from functools import partial +``` + +Replace the `add_middleware` method: + +```python + def add_middleware( + self, + middleware_class: type[BaseMiddleware] | Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> None: + """Add middleware to the stack. + + Extra positional/keyword args are bound to the middleware's + constructor (after `app`), so config no longer requires + `functools.partial`: + + app.add_middleware(CORSMiddleware, allow_origins=["https://example.com"]) + + A bare class or an already-partial'd callable still works unchanged. + """ + if args or kwargs: + middleware_class = partial(middleware_class, *args, **kwargs) + self._middleware_stack.append(middleware_class) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_app_sugar.py -v` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add_middleware accepts config directly, no functools.partial needed" +``` + +--- + +### Task 2: `enable_*` convenience methods on `Velocix` + +**Files:** +- Modify: `velocix/core/app.py` (add methods immediately after `add_middleware`) +- Test: `tests/test_app_sugar.py` (append to file from Task 1) + +**Interfaces:** +- Consumes: `Velocix.add_middleware(middleware_class, *args, **kwargs)` from Task 1. +- Produces: `Velocix.enable_cors(...)`, `.enable_csrf(secret_key, **kwargs)`, `.enable_sessions(secret_key, **kwargs)`, `.enable_rate_limit(limit=100, window=60)`, `.enable_gzip(minimum_size=500, compresslevel=9)`, `.enable_trusted_hosts(allowed_hosts)` — each registers the corresponding middleware via `add_middleware`. + +Each method below is its own test-then-implement-then-commit cycle. + +#### 2a. `enable_cors` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_enable_cors_adds_allow_origin_header(): + app = Velocix() + app.enable_cors(allow_origins=["https://example.com"]) + + @app.get("/ping") + async def ping(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping", headers={"origin": "https://example.com"}) + assert resp.headers["access-control-allow-origin"] == "https://example.com" + + _run(scenario()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_enable_cors_adds_allow_origin_header -v` +Expected: FAIL with `AttributeError: 'Velocix' object has no attribute 'enable_cors'` + +- [ ] **Step 3: Implement `enable_cors`** + +In `velocix/core/app.py`, add immediately after `add_middleware`: + +```python + def enable_cors( + self, + allow_origins: list[str] | None = None, + allow_methods: list[str] | None = None, + allow_headers: list[str] | None = None, + allow_credentials: bool = False, + max_age: int = 600, + allow_origin_regex: str | None = None, + ) -> None: + """Add CORS support. Wraps velocix.security.cors.CORSMiddleware.""" + from velocix.security.cors import CORSMiddleware + + self.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_methods=allow_methods, + allow_headers=allow_headers, + allow_credentials=allow_credentials, + max_age=max_age, + allow_origin_regex=allow_origin_regex, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_enable_cors_adds_allow_origin_header -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add enable_cors convenience method" +``` + +#### 2b. `enable_csrf` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_enable_csrf_blocks_post_without_token(): + app = Velocix() + app.enable_csrf(secret_key="test-secret") + + @app.post("/page") + async def post_page(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.post("/page") + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == "CSRF_COOKIE_MISSING" + + _run(scenario()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_enable_csrf_blocks_post_without_token -v` +Expected: FAIL with `AttributeError: 'Velocix' object has no attribute 'enable_csrf'` + +- [ ] **Step 3: Implement `enable_csrf`** + +Add immediately after `enable_cors`: + +```python + def enable_csrf(self, secret_key: str, **kwargs: Any) -> None: + """Add CSRF double-submit-cookie protection. Wraps + velocix.security.csrf.CSRFMiddleware. Extra kwargs (cookie_name, + exempt_paths, etc.) pass through.""" + from velocix.security.csrf import CSRFMiddleware + + self.add_middleware(CSRFMiddleware, secret_key=secret_key, **kwargs) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_enable_csrf_blocks_post_without_token -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add enable_csrf convenience method" +``` + +#### 2c. `enable_sessions` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_enable_sessions_round_trips_value(): + app = Velocix() + app.enable_sessions(secret_key="test-secret") + + @app.get("/set") + async def set_session(request): + request.session["user"] = "alice" + return {"ok": True} + + @app.get("/read") + async def read_session(request): + return dict(request.session) + + async def scenario(): + async with TestClient(app) as client: + await client.get("/set") + resp = await client.get("/read") + assert resp.json() == {"user": "alice"} + + _run(scenario()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_enable_sessions_round_trips_value -v` +Expected: FAIL with `AttributeError: 'Velocix' object has no attribute 'enable_sessions'` + +- [ ] **Step 3: Implement `enable_sessions`** + +Add immediately after `enable_csrf`: + +```python + def enable_sessions(self, secret_key: str, **kwargs: Any) -> None: + """Add signed cookie sessions. Wraps + velocix.core.middleware.SessionMiddleware. Extra kwargs (max_age, + same_site, etc.) pass through.""" + from velocix.core.middleware import SessionMiddleware + + self.add_middleware(SessionMiddleware, secret_key=secret_key, **kwargs) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_enable_sessions_round_trips_value -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add enable_sessions convenience method" +``` + +#### 2d. `enable_rate_limit` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_enable_rate_limit_returns_429_after_limit(): + app = Velocix() + app.enable_rate_limit(limit=2, window=60) + + @app.get("/ping") + async def ping(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + assert (await client.get("/ping")).status_code == 200 + assert (await client.get("/ping")).status_code == 200 + resp = await client.get("/ping") + assert resp.status_code == 429 + + _run(scenario()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_enable_rate_limit_returns_429_after_limit -v` +Expected: FAIL with `AttributeError: 'Velocix' object has no attribute 'enable_rate_limit'` + +- [ ] **Step 3: Implement `enable_rate_limit`** + +Add immediately after `enable_sessions`: + +```python + def enable_rate_limit(self, limit: int = 100, window: float = 60) -> None: + """Add a global rate limit. Wraps + velocix.security.ratelimit.RateLimitMiddleware with a + ProductionRateLimiter configured for a single global sliding + window.""" + from velocix.security.ratelimit import ProductionRateLimiter, RateLimitMiddleware + + limiter = ProductionRateLimiter() + limiter.set_global_window(limit=limit, window_size=window) + self.add_middleware(RateLimitMiddleware, limiter=limiter) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_enable_rate_limit_returns_429_after_limit -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add enable_rate_limit convenience method" +``` + +#### 2e. `enable_gzip` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_enable_gzip_compresses_large_response(): + app = Velocix() + app.enable_gzip(minimum_size=10) + + @app.get("/big") + async def big(): + return {"data": "x" * 1000} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/big", headers={"accept-encoding": "gzip"}) + assert resp.headers.get("content-encoding") == "gzip" + + _run(scenario()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_enable_gzip_compresses_large_response -v` +Expected: FAIL with `AttributeError: 'Velocix' object has no attribute 'enable_gzip'` + +- [ ] **Step 3: Implement `enable_gzip`** + +Add immediately after `enable_rate_limit`: + +```python + def enable_gzip(self, minimum_size: int = 500, compresslevel: int = 9) -> None: + """Add gzip response compression. Wraps + velocix.core.middleware.GZipMiddleware.""" + from velocix.core.middleware import GZipMiddleware + + self.add_middleware( + GZipMiddleware, minimum_size=minimum_size, compresslevel=compresslevel + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_enable_gzip_compresses_large_response -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add enable_gzip convenience method" +``` + +#### 2f. `enable_trusted_hosts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_enable_trusted_hosts_rejects_unlisted_host(): + app = Velocix() + app.enable_trusted_hosts(["example.com"]) + + @app.get("/ping") + async def ping(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping", headers={"host": "evil.com"}) + assert resp.status_code == 400 + + _run(scenario()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_enable_trusted_hosts_rejects_unlisted_host -v` +Expected: FAIL with `AttributeError: 'Velocix' object has no attribute 'enable_trusted_hosts'` + +- [ ] **Step 3: Implement `enable_trusted_hosts`** + +Add immediately after `enable_gzip`: + +```python + def enable_trusted_hosts(self, allowed_hosts: list[str]) -> None: + """Restrict accepted Host headers. Wraps + velocix.core.middleware.TrustedHostMiddleware.""" + from velocix.core.middleware import TrustedHostMiddleware + + self.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_enable_trusted_hosts_rejects_unlisted_host -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add velocix/core/app.py tests/test_app_sugar.py +git commit -m "feat(app): add enable_trusted_hosts convenience method" +``` + +--- + +### Task 3: Top-level export audit + +**Files:** +- Modify: `velocix/__init__.py` +- Test: `tests/test_app_sugar.py` (append) + +**Interfaces:** +- Produces: `velocix.TrustedHostMiddleware`, `velocix.GZipMiddleware`, `velocix.BaseHTTPMiddleware`, `velocix.CSRFMiddleware`, `velocix.Body`, `velocix.Struct` (== `msgspec.Struct`) all importable from the top-level `velocix` package. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_app_sugar.py`: + +```python +def test_top_level_exports_resolve(): + import msgspec + + from velocix import ( + BaseHTTPMiddleware, + Body, + CSRFMiddleware, + GZipMiddleware, + Struct, + TrustedHostMiddleware, + ) + + assert Struct is msgspec.Struct + assert Body is not None + assert TrustedHostMiddleware is not None + assert GZipMiddleware is not None + assert BaseHTTPMiddleware is not None + assert CSRFMiddleware is not None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app_sugar.py::test_top_level_exports_resolve -v` +Expected: FAIL with `ImportError: cannot import name 'BaseHTTPMiddleware' from 'velocix'` + +- [ ] **Step 3: Add the exports** + +In `velocix/__init__.py`: + +Change the middleware import line: + +```python +from velocix.core.middleware import BaseMiddleware, SessionMiddleware +``` + +to: + +```python +from velocix.core.middleware import ( + BaseHTTPMiddleware, + BaseMiddleware, + GZipMiddleware, + SessionMiddleware, + TrustedHostMiddleware, +) +``` + +Change the params import line: + +```python +from velocix.core.params import Cookie, File, Form, Header, Query +``` + +to: + +```python +from velocix.core.params import Body, Cookie, File, Form, Header, Query +``` + +Add a new import for `Struct` (as a direct `msgspec.Struct` re-export) and +`CSRFMiddleware`, near the existing `from velocix.security.cors import +CORSMiddleware` line: + +```python +from velocix.security.cors import CORSMiddleware +from velocix.security.csrf import CSRFMiddleware +``` + +And near the top, alongside other stdlib-adjacent imports: + +```python +from msgspec import Struct +``` + +Finally, add all six new names to `__all__` (insert into the existing list, +keeping the grouped-by-section style already there): + +```python + "BaseHTTPMiddleware", + "TrustedHostMiddleware", + "GZipMiddleware", + "CSRFMiddleware", + "Body", + "Struct", +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app_sugar.py::test_top_level_exports_resolve -v` +Expected: PASS + +- [ ] **Step 5: Run the full test suite to confirm no regressions** + +Run: `pytest -q` +Expected: zero failures — the full existing suite plus the 10 new tests +added across Tasks 1-3 (3 in Task 1, 6 in Task 2, 1 in Task 3), all passing. + +- [ ] **Step 6: Commit** + +```bash +git add velocix/__init__.py tests/test_app_sugar.py +git commit -m "feat: export BaseHTTPMiddleware, TrustedHostMiddleware, GZipMiddleware, CSRFMiddleware, Body, Struct from top-level velocix" +``` + +--- + +### Task 4: README + example refresh + +**Files:** +- Modify: `README.md` +- Modify: `examples/openapi_example.py` + +**Interfaces:** +- Consumes: `Velocix.enable_cors` (Task 2a), top-level `Struct` (Task 3). +- No new interfaces produced — documentation/example only. + +- [ ] **Step 1: Update the README quickstart** + +In `README.md`, after the existing Quick Start code block (currently ending +around line 111 with the `uvicorn.run(app, ...)` block), add a new +subsection: + +```markdown +### Middleware & security + +Common setups are one call — no need to import `functools.partial` or know +the underlying middleware class names: + +\`\`\`python +app.enable_cors(allow_origins=["https://example.com"]) +app.enable_csrf(secret_key="your-secret") +app.enable_sessions(secret_key="your-secret") +app.enable_rate_limit(limit=100, window=60) +app.enable_gzip() +app.enable_trusted_hosts(["example.com"]) +\`\`\` + +For anything these don't cover, `app.add_middleware(MiddlewareClass, +**kwargs)` works directly — no `functools.partial` needed there either. +``` + +(Use literal triple-backtick fences, not escaped, when editing the file — +the `\`\`\`` above is just to avoid closing this plan's own code block +early.) + +- [ ] **Step 2: Update the OpenAPI example's import** + +In `examples/openapi_example.py`, change: + +```python +from velocix.validation import Struct +``` + +to: + +```python +from velocix import Struct +``` + +- [ ] **Step 3: Remove the redundant `enable_auto_docs` call** + +In `examples/openapi_example.py`, delete the entire block starting at +`# Enable automatic OpenAPI documentation` through the closing `)` of the +`enable_auto_docs(...)` call (the block immediately before the +`if __name__ == "__main__":` section). `Velocix()` already registers +`/docs`, `/redoc`, and `/openapi.json` automatically at app creation +(`velocix/core/app.py::_setup_docs_routes`), so this call was generating +those same routes a second time via the separate legacy pipeline. + +Also remove the now-unused import at the top of the file: + +```python +from velocix.openapi import enable_auto_docs +``` + +- [ ] **Step 4: Verify the example still parses and runs** + +Run: `python -c "import ast; ast.parse(open('examples/openapi_example.py').read())"` +Expected: no output (valid syntax). + +Run: `python -c "import examples.openapi_example as m; print(m.app.docs_url, m.app.openapi_url)"` +Expected: prints `/docs /openapi.json` — confirms the app still has working +docs routes via the automatic mechanism, without the removed manual call. + +- [ ] **Step 5: Run the full test suite, ruff, and mypy** + +Run: `pytest -q && ruff check . && mypy velocix` +Expected: zero test failures, zero ruff violations, zero mypy errors — +matching the standard the repo already holds itself to (see commit 6ac2f55, +which reported "262/262 tests, mypy clean, ruff clean" for its own change). + +- [ ] **Step 6: Commit** + +```bash +git add README.md examples/openapi_example.py +git commit -m "docs: demonstrate enable_* helpers in README and drop redundant enable_auto_docs from example" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Component 1 → Task 1. Component 2 (all six methods) → + Task 2a-2f. Component 3 → Task 3. Component 4 → Task 4. Testing section → + covered across all tasks in `tests/test_app_sugar.py`. Compatibility + section → enforced by Global Constraints and the regression tests in + Task 1 (bare class / `partial` still work). +- **Placeholder scan:** none found — every step has runnable code. +- **Type consistency:** `add_middleware(middleware_class, *args, **kwargs)` + signature from Task 1 is reused identically by every `enable_*` method in + Task 2. `Struct` in Task 3 is the same object referenced in Task 4 Step 2. From 65fc22f306b4d5171b9281cbb307f4a10842228b Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:25:02 +0530 Subject: [PATCH 03/11] feat(app): add_middleware accepts config directly, no functools.partial needed --- tests/test_app_sugar.py | 80 +++++++++++++++++++++++++++++++++++++++++ velocix/core/app.py | 21 +++++++++-- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 tests/test_app_sugar.py diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py new file mode 100644 index 0000000..c54e977 --- /dev/null +++ b/tests/test_app_sugar.py @@ -0,0 +1,80 @@ +"""Tests for the additive API-simplification sugar layer: add_middleware +kwargs support, enable_* convenience methods, and top-level exports. +""" + +import asyncio +from functools import partial + +from velocix import TestClient, Velocix +from velocix.core.middleware import BaseMiddleware + + +def _run(coro): + return asyncio.run(coro) + + +class _RecordingMiddleware(BaseMiddleware): + """Records the kwargs it was constructed with.""" + + last_kwargs: dict = {} + + def __init__(self, app, **kwargs): + super().__init__(app) + _RecordingMiddleware.last_kwargs = kwargs + + async def __call__(self, request): + return await self.app(request) + + +def test_add_middleware_accepts_kwargs_without_partial(): + app = Velocix() + + @app.get("/ping") + async def ping(): + return {"ok": True} + + app.add_middleware(_RecordingMiddleware, greeting="hi") + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + + _run(scenario()) + assert _RecordingMiddleware.last_kwargs == {"greeting": "hi"} + + +def test_add_middleware_bare_class_still_works(): + app = Velocix() + + @app.get("/ping") + async def ping(): + return {"ok": True} + + app.add_middleware(_RecordingMiddleware) + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + + _run(scenario()) + assert _RecordingMiddleware.last_kwargs == {} + + +def test_add_middleware_with_partial_still_works(): + app = Velocix() + + @app.get("/ping") + async def ping(): + return {"ok": True} + + app.add_middleware(partial(_RecordingMiddleware, greeting="via-partial")) + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping") + assert resp.status_code == 200 + + _run(scenario()) + assert _RecordingMiddleware.last_kwargs == {"greeting": "via-partial"} diff --git a/velocix/core/app.py b/velocix/core/app.py index ecfffaa..b2edf67 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -6,6 +6,7 @@ import logging import time from collections.abc import Awaitable, Callable, Coroutine +from functools import partial from typing import Any import msgspec @@ -370,8 +371,24 @@ def websocket( """Decorator for WebSocket routes""" return self.route(path, {"WEBSOCKET"}, name=name) - def add_middleware(self, middleware_class: type[BaseMiddleware] | Callable[..., Any]) -> None: - """Add middleware to stack""" + def add_middleware( + self, + middleware_class: type[BaseMiddleware] | Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> None: + """Add middleware to the stack. + + Extra positional/keyword args are bound to the middleware's + constructor (after `app`), so config no longer requires + `functools.partial`: + + app.add_middleware(CORSMiddleware, allow_origins=["https://example.com"]) + + A bare class or an already-partial'd callable still works unchanged. + """ + if args or kwargs: + middleware_class = partial(middleware_class, *args, **kwargs) self._middleware_stack.append(middleware_class) def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: From 039d1e255c8983e7ba6ce7473f36007554b52724 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:25:41 +0530 Subject: [PATCH 04/11] feat(app): add enable_cors convenience method --- tests/test_app_sugar.py | 16 ++++++++++++++++ velocix/core/app.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index c54e977..bf84212 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -78,3 +78,19 @@ async def scenario(): _run(scenario()) assert _RecordingMiddleware.last_kwargs == {"greeting": "via-partial"} + + +def test_enable_cors_adds_allow_origin_header(): + app = Velocix() + app.enable_cors(allow_origins=["https://example.com"]) + + @app.get("/ping") + async def ping(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping", headers={"origin": "https://example.com"}) + assert resp.headers["access-control-allow-origin"] == "https://example.com" + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index b2edf67..627f439 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -391,6 +391,28 @@ def add_middleware( middleware_class = partial(middleware_class, *args, **kwargs) self._middleware_stack.append(middleware_class) + def enable_cors( + self, + allow_origins: list[str] | None = None, + allow_methods: list[str] | None = None, + allow_headers: list[str] | None = None, + allow_credentials: bool = False, + max_age: int = 600, + allow_origin_regex: str | None = None, + ) -> None: + """Add CORS support. Wraps velocix.security.cors.CORSMiddleware.""" + from velocix.security.cors import CORSMiddleware + + self.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_methods=allow_methods, + allow_headers=allow_headers, + allow_credentials=allow_credentials, + max_age=max_age, + allow_origin_regex=allow_origin_regex, + ) + def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: """Merge another router's routes into this app, optionally under a prefix. From 66568359e601fa6cbaa7e7330a1e59eefae27022 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:26:08 +0530 Subject: [PATCH 05/11] feat(app): add enable_csrf convenience method --- tests/test_app_sugar.py | 17 +++++++++++++++++ velocix/core/app.py | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index bf84212..fde160b 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -94,3 +94,20 @@ async def scenario(): assert resp.headers["access-control-allow-origin"] == "https://example.com" _run(scenario()) + + +def test_enable_csrf_blocks_post_without_token(): + app = Velocix() + app.enable_csrf(secret_key="test-secret") + + @app.post("/page") + async def post_page(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.post("/page") + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == "CSRF_COOKIE_MISSING" + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index 627f439..c411d15 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -413,6 +413,14 @@ def enable_cors( allow_origin_regex=allow_origin_regex, ) + def enable_csrf(self, secret_key: str, **kwargs: Any) -> None: + """Add CSRF double-submit-cookie protection. Wraps + velocix.security.csrf.CSRFMiddleware. Extra kwargs (cookie_name, + exempt_paths, etc.) pass through.""" + from velocix.security.csrf import CSRFMiddleware + + self.add_middleware(CSRFMiddleware, secret_key=secret_key, **kwargs) + def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: """Merge another router's routes into this app, optionally under a prefix. From 4bc56b1bec337b68ccfb99b4f1bbebd058f2a1dd Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:26:40 +0530 Subject: [PATCH 06/11] feat(app): add enable_sessions convenience method --- tests/test_app_sugar.py | 22 ++++++++++++++++++++++ velocix/core/app.py | 8 ++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index fde160b..88f38be 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -111,3 +111,25 @@ async def scenario(): assert resp.json()["error"]["code"] == "CSRF_COOKIE_MISSING" _run(scenario()) + + +def test_enable_sessions_round_trips_value(): + app = Velocix() + app.enable_sessions(secret_key="test-secret") + + @app.get("/set") + async def set_session(request): + request.session["user"] = "alice" + return {"ok": True} + + @app.get("/read") + async def read_session(request): + return dict(request.session) + + async def scenario(): + async with TestClient(app) as client: + await client.get("/set") + resp = await client.get("/read") + assert resp.json() == {"user": "alice"} + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index c411d15..9bdd658 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -421,6 +421,14 @@ def enable_csrf(self, secret_key: str, **kwargs: Any) -> None: self.add_middleware(CSRFMiddleware, secret_key=secret_key, **kwargs) + def enable_sessions(self, secret_key: str, **kwargs: Any) -> None: + """Add signed cookie sessions. Wraps + velocix.core.middleware.SessionMiddleware. Extra kwargs (max_age, + same_site, etc.) pass through.""" + from velocix.core.middleware import SessionMiddleware + + self.add_middleware(SessionMiddleware, secret_key=secret_key, **kwargs) + def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: """Merge another router's routes into this app, optionally under a prefix. From 38780496261517be5c2bce64d54d226a8e0f2f83 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:27:08 +0530 Subject: [PATCH 07/11] feat(app): add enable_rate_limit convenience method --- tests/test_app_sugar.py | 18 ++++++++++++++++++ velocix/core/app.py | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index 88f38be..9799cd4 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -133,3 +133,21 @@ async def scenario(): assert resp.json() == {"user": "alice"} _run(scenario()) + + +def test_enable_rate_limit_returns_429_after_limit(): + app = Velocix() + app.enable_rate_limit(limit=2, window=60) + + @app.get("/ping") + async def ping(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + assert (await client.get("/ping")).status_code == 200 + assert (await client.get("/ping")).status_code == 200 + resp = await client.get("/ping") + assert resp.status_code == 429 + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index 9bdd658..630a272 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -429,6 +429,17 @@ def enable_sessions(self, secret_key: str, **kwargs: Any) -> None: self.add_middleware(SessionMiddleware, secret_key=secret_key, **kwargs) + def enable_rate_limit(self, limit: int = 100, window: float = 60) -> None: + """Add a global rate limit. Wraps + velocix.security.ratelimit.RateLimitMiddleware with a + ProductionRateLimiter configured for a single global sliding + window.""" + from velocix.security.ratelimit import ProductionRateLimiter, RateLimitMiddleware + + limiter = ProductionRateLimiter() + limiter.set_global_window(limit=limit, window_size=window) + self.add_middleware(RateLimitMiddleware, limiter=limiter) + def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: """Merge another router's routes into this app, optionally under a prefix. From 8769802eef64ddf371b7190dfd98c115107ea6f5 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:27:34 +0530 Subject: [PATCH 08/11] feat(app): add enable_gzip convenience method --- tests/test_app_sugar.py | 16 ++++++++++++++++ velocix/core/app.py | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index 9799cd4..e789ab1 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -151,3 +151,19 @@ async def scenario(): assert resp.status_code == 429 _run(scenario()) + + +def test_enable_gzip_compresses_large_response(): + app = Velocix() + app.enable_gzip(minimum_size=10) + + @app.get("/big") + async def big(): + return {"data": "x" * 1000} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/big", headers={"accept-encoding": "gzip"}) + assert resp.headers.get("content-encoding") == "gzip" + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index 630a272..6e661dc 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -440,6 +440,15 @@ def enable_rate_limit(self, limit: int = 100, window: float = 60) -> None: limiter.set_global_window(limit=limit, window_size=window) self.add_middleware(RateLimitMiddleware, limiter=limiter) + def enable_gzip(self, minimum_size: int = 500, compresslevel: int = 9) -> None: + """Add gzip response compression. Wraps + velocix.core.middleware.GZipMiddleware.""" + from velocix.core.middleware import GZipMiddleware + + self.add_middleware( + GZipMiddleware, minimum_size=minimum_size, compresslevel=compresslevel + ) + def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: """Merge another router's routes into this app, optionally under a prefix. From 52ad845664bce2d4e0980f251caead953d7e6f31 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:28:01 +0530 Subject: [PATCH 09/11] feat(app): add enable_trusted_hosts convenience method --- tests/test_app_sugar.py | 16 ++++++++++++++++ velocix/core/app.py | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index e789ab1..07297db 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -167,3 +167,19 @@ async def scenario(): assert resp.headers.get("content-encoding") == "gzip" _run(scenario()) + + +def test_enable_trusted_hosts_rejects_unlisted_host(): + app = Velocix() + app.enable_trusted_hosts(["example.com"]) + + @app.get("/ping") + async def ping(): + return {"ok": True} + + async def scenario(): + async with TestClient(app) as client: + resp = await client.get("/ping", headers={"host": "evil.com"}) + assert resp.status_code == 400 + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index 6e661dc..ef90684 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -449,6 +449,13 @@ def enable_gzip(self, minimum_size: int = 500, compresslevel: int = 9) -> None: GZipMiddleware, minimum_size=minimum_size, compresslevel=compresslevel ) + def enable_trusted_hosts(self, allowed_hosts: list[str]) -> None: + """Restrict accepted Host headers. Wraps + velocix.core.middleware.TrustedHostMiddleware.""" + from velocix.core.middleware import TrustedHostMiddleware + + self.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) + def include_router(self, router: Router, prefix: str = "", tags: list[str] | None = None) -> None: """Merge another router's routes into this app, optionally under a prefix. From 1d99029b2cdd4bd52fd09ccb62c345f063659ce8 Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:29:12 +0530 Subject: [PATCH 10/11] feat: export BaseHTTPMiddleware, TrustedHostMiddleware, GZipMiddleware, CSRFMiddleware, Body, Struct from top-level velocix --- tests/test_app_sugar.py | 20 ++++++++++++++++++++ velocix/__init__.py | 19 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/test_app_sugar.py b/tests/test_app_sugar.py index 07297db..0f04224 100644 --- a/tests/test_app_sugar.py +++ b/tests/test_app_sugar.py @@ -183,3 +183,23 @@ async def scenario(): assert resp.status_code == 400 _run(scenario()) + + +def test_top_level_exports_resolve(): + import msgspec + + from velocix import ( + BaseHTTPMiddleware, + Body, + CSRFMiddleware, + GZipMiddleware, + Struct, + TrustedHostMiddleware, + ) + + assert Struct is msgspec.Struct + assert Body is not None + assert TrustedHostMiddleware is not None + assert GZipMiddleware is not None + assert BaseHTTPMiddleware is not None + assert CSRFMiddleware is not None diff --git a/velocix/__init__.py b/velocix/__init__.py index 1b570d4..46d74b5 100644 --- a/velocix/__init__.py +++ b/velocix/__init__.py @@ -31,11 +31,19 @@ from functools import partial +from msgspec import Struct + from velocix.core.app import Velocix, cache_response from velocix.core.depends import Depends from velocix.core.exceptions import HTTPException, NoMatchFound -from velocix.core.middleware import BaseMiddleware, SessionMiddleware -from velocix.core.params import Cookie, File, Form, Header, Query +from velocix.core.middleware import ( + BaseHTTPMiddleware, + BaseMiddleware, + GZipMiddleware, + SessionMiddleware, + TrustedHostMiddleware, +) +from velocix.core.params import Body, Cookie, File, Form, Header, Query from velocix.core.request import Request from velocix.core.response import ( EventStreamResponse, @@ -60,6 +68,7 @@ from velocix.openapi.decorators import operation, parameter, response from velocix.openapi.generator import OpenAPIGenerator from velocix.security.cors import CORSMiddleware +from velocix.security.csrf import CSRFMiddleware # Security from velocix.security.jwt import JWTHandler, JWTManager @@ -127,8 +136,12 @@ def create_app( "HTTPException", "NoMatchFound", "BaseMiddleware", + "BaseHTTPMiddleware", + "TrustedHostMiddleware", + "GZipMiddleware", "Depends", "SessionMiddleware", + "Struct", # Responses "StreamingResponse", "FileResponse", @@ -148,6 +161,7 @@ def create_app( "Cookie", "Form", "File", + "Body", # Uploads "UploadFile", "MultipartForm", @@ -159,6 +173,7 @@ def create_app( "PasswordManager", "PasswordHasher", "CORSMiddleware", + "CSRFMiddleware", "RateLimitMiddleware", # HTTP & WebSocket "HTTPClient", From d3d01726ee5d320f4f7163735100a316404b998a Mon Sep 17 00:00:00 2001 From: magi Date: Sat, 19 Sep 2026 21:30:22 +0530 Subject: [PATCH 11/11] docs: demonstrate enable_* helpers in README and drop redundant enable_auto_docs from example --- README.md | 17 ++++++++++++ examples/openapi_example.py | 52 +------------------------------------ 2 files changed, 18 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b6835a7..d6143d5 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,23 @@ if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) ``` +### Middleware & security + +Common setups are one call — no need to import `functools.partial` or know +the underlying middleware class names: + +```python +app.enable_cors(allow_origins=["https://example.com"]) +app.enable_csrf(secret_key="your-secret") +app.enable_sessions(secret_key="your-secret") +app.enable_rate_limit(limit=100, window=60) +app.enable_gzip() +app.enable_trusted_hosts(["example.com"]) +``` + +For anything these don't cover, `app.add_middleware(MiddlewareClass, **kwargs)` +works directly — no `functools.partial` needed there either. + --- ## Performance diff --git a/examples/openapi_example.py b/examples/openapi_example.py index cff5e9c..2633abc 100644 --- a/examples/openapi_example.py +++ b/examples/openapi_example.py @@ -8,10 +8,8 @@ Following FastAPI's approach for automatic OpenAPI generation. """ -from velocix import Velocix +from velocix import Struct, Velocix from velocix.core.depends import Depends -from velocix.openapi import enable_auto_docs -from velocix.validation import Struct # Data models using msgspec Struct (Velocix's validation system) @@ -401,54 +399,6 @@ async def upload_avatar( } -# Enable automatic OpenAPI documentation -enable_auto_docs( - app, - title="Velocix API with Proper OpenAPI", - version="1.0.0", - description=""" - # Velocix OpenAPI Example - - This API demonstrates proper OpenAPI 3.0 parameter handling: - - ## Parameter Types - - ### Path Parameters - - In the URL path: `/users/{user_id}` - - Always required - - OpenAPI: `in: path` - - ### Query Parameters - - In the query string: `?skip=0&limit=10` - - Usually optional with defaults - - Scalar types (str, int, bool) - - OpenAPI: `in: query` - - ### Request Body - - In POST/PUT/PATCH request body - - Complex types (Structs, models, dicts) - - Content-Type: application/json - - OpenAPI: `requestBody` object (NOT in parameters array) - - ## Key Principles - - 1. **Separation**: Path/query params go in `parameters[]`, body data goes in `requestBody` - 2. **Method-specific**: GET/DELETE use parameters only, POST/PUT/PATCH can have requestBody - 3. **Type-based**: Scalar types → query params, Complex types → request body - 4. **Dependencies**: Dependency injection params are internal, not in OpenAPI - - ## Examples - - - GET with query params: `/users?skip=0&limit=10` - - GET with path param: `/users/123` - - POST with body: `POST /users` + JSON body - - POST with mixed: `POST /users/123/posts?notify=true` + JSON body - """, - openapi_url="/openapi.json", - docs_url="/docs", - redoc_url="/redoc" -) - if __name__ == "__main__": print("=" * 80)