From 624e30ab3aef76ec310f159d5d18de9a371fe151 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 20 Jul 2026 13:05:03 +0300 Subject: [PATCH 1/3] feat(openapi): docs UI, path/JWT enrichment, Granian lifespan Ship Scalar/Swagger at /docs, OpenAPI {param} paths with parameters and bearerAuth, tags/info/servers hooks, and sync __rsgi_init__(loop) via on_startup so Granian actually runs worker startup. Closes #130. --- CHANGELOG.md | 19 +++++ docs/index.md | 2 +- docs/openapi.md | 52 ++++++++++-- docs/rsgi.md | 26 ++++-- docs/usage.md | 15 ++-- examples/rsgi_lifespan_app.py | 17 ++-- oxyroute/app.py | 125 +++++++++++++++++++++++++--- oxyroute/docs_ui.py | 81 ++++++++++++++++++ oxyroute/router.py | 16 +++- src/lib.rs | 150 +++++++++++++++++++++++++++++++++- tests/test_openapi.py | 115 +++++++++++++++++++++++++- tests/test_rsgi_lifespan.py | 37 ++++++++- 12 files changed, 608 insertions(+), 47 deletions(-) create mode 100644 oxyroute/docs_ui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6223b34..a674e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- First-class OpenAPI docs UI: `App(..., docs_ui="scalar"|"swagger")` and + `app.mount_docs(...)` serve CDN-backed Scalar / Swagger UI at `/docs` (or a custom + path) against `/openapi.json` ([#130](https://github.com/QueryaHub/OxyRoute/issues/130)). +- OpenAPI enrichment for interactive explorers: matchit `:param` / `*rest` → `{param}` / + `{rest}` with path `parameters`; JWT `bearerAuth` security scheme when + `require_jwt=True`; operation `tags=` and `include_router(..., tags=[...])`; + `set_openapi_info` / constructor `openapi_description` / `openapi_contact` / + `openapi_servers`. +- Granian-compatible lifespan: sync `__rsgi_init__(loop)` / `__rsgi_del__(loop)` run + `on_startup` / `on_shutdown` via `loop.run_until_complete`. Prefer overriding + `on_startup` / `on_shutdown` instead of async `__rsgi_init__`. + +### Changed + +- OpenAPI path keys use `{param}` form (breaking for consumers that asserted matchit + `:param` strings in `openapi_json()`). + ## [0.3.0] - 2026-04-27 ### Added diff --git a/docs/index.md b/docs/index.md index 9c9af3f..25f6d1c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [WebSockets](websocket.md) | Native RSGI `@app.websocket(path)` and `oxyroute.WebSocket` | | [HTTP/2 with Granian](http2.md) | Transport guarantees vs server/proxy responsibilities | | [Dependencies](dependencies.md) | `Depends`, `dependencies=[...]`, `freeze` | -| [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` | +| [OpenAPI](openapi.md) | `openapi.json`, docs UI (Scalar/Swagger), tags, JWT security | | [Development](development.md) | Tests, CI, PyPI releases (tag `v*`), clippy, pytest | | [Branching and PRs](development-workflow.md) | `dev` as base, issue branches, `Closes #N`, no mixing code with `ISSUE_BACKLOG` in one commit | | [Feature gaps (research)](feature.md) | What is missing vs a “full” HTTP framework and what has been implemented — Russian | diff --git a/docs/openapi.md b/docs/openapi.md index 2e974b0..eb9237a 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -2,7 +2,7 @@ [← Documentation index](index.md) -OxyRoute maintains a small **OpenAPI 3.0**-shaped JSON document in Rust while routes are registered. It is **not** a full OpenAPI model of every type and body schema; it is a **minimal** view suitable for discovery and tooling, and can be extended in future versions. +OxyRoute maintains an **OpenAPI 3.0**-shaped JSON document in Rust while routes are registered. It is suitable for discovery and interactive docs (Scalar / Swagger UI), and can be extended further in future versions. ## Constructor and toggles @@ -15,16 +15,55 @@ Served vs built: one flag, two ways to set it. - **While serving is off** (`include_openapi=False` at build time, or after `set_openapi_served(False)`): - The engine does **not** return the spec for **`GET /openapi.json`** or **`HEAD /openapi.json`**. The request is **not** special-cased, so it goes through normal routing. Unless you add your own handler for that path, the client usually gets **404 Not Found**. If you **do** register a handler for `/openapi.json`, that handler can serve a custom response. - Route registration still **merges** operations into the in-memory OpenAPI document. The document is not discarded. -- **Export:** **`openapi_json()`** on the Python `App` still returns the current JSON as a string (handy in tests, admin tools, or a custom response), regardless of the serving toggle. +- **Export:** **`openapi_json()`** on the Python `App` still returns the current JSON as a string (handy in tests, admin tools, or a custom response), regardless of the serving toggle. The exported document is the **same** enriched spec the UI uses. -## Title and export +## Docs UI (Scalar / Swagger) -- **`set_openapi_title`** is applied from `App(..., title="...")` at construction. -- **`openapi_json()`** is described above; it is independent of whether `/openapi.json` is exposed over HTTP. +Optional interactive explorer (CDN-backed HTML): + +```python +from oxyroute import App + +app = App(title="My API", docs_ui="scalar") # or "swagger" +# → GET /docs + +# or later / custom path: +app.mount_docs("/api/docs", ui="swagger") +``` + +| Option | Meaning | +|--------|---------| +| `docs_ui="scalar"` \| `"swagger"` | Mount `GET /docs` at construction | +| `mount_docs(path, ui=...)` | Mount at a custom path | +| Spec URL | Built-in `/openapi.json` | + +UI scripts load from **jsDelivr**. If you use `SecurityHeadersConfig` (or a strict CSP), allow `cdn.jsdelivr.net` in `script-src` / `style-src` for the docs route, or disable those headers on `/docs`. + +## Title, info, and servers + +- **`title=`** / **`set_openapi_title`** — `info.title`. +- **`openapi_description=`**, **`openapi_contact=`**, **`openapi_servers=`** constructor kwargs, or **`app.set_openapi_info(description=..., contact=..., servers=...)`**. + +```python +app = App( + title="Market API", + openapi_description="Public marketplace HTTP API", + openapi_contact={"name": "API", "email": "api@example.com"}, + openapi_servers=[{"url": "https://api.example.com"}], + docs_ui="scalar", +) +``` ## What is in the document today -Per route, the code records path, method, a short `summary` / `operationId` derived from the **handler’s** `__name__`, and a simple `200` response placeholder. +Per route, the code records: + +- OpenAPI path templates: matchit **`:id` → `{id}`**, catch-all **`*rest` → `{rest}`** +- Path **`parameters`** (`in: path`, `required: true`, string schema) +- Method, short `summary` / `operationId` from the handler’s `__name__` +- Simple `200` response placeholder +- Optional **`tags`** from the route decorator or `include_router(..., tags=[...])` (per-route `tags=` wins over include defaults) +- If **`require_jwt=True`**: `components.securitySchemes.bearerAuth` (`http` + `bearer` + `JWT`) and `security: [{ bearerAuth: [] }]` on that operation For **`POST`**, **`PUT`**, and **`PATCH`**, you can document the JSON request body in OpenAPI in two ways (pass **at most one**): @@ -35,3 +74,4 @@ For **`POST`**, **`PUT`**, and **`PATCH`**, you can document the JSON request bo - [Routing](routing.md) - [Handlers](handlers.md) +- [RSGI / lifespan](rsgi.md) diff --git a/docs/rsgi.md b/docs/rsgi.md index e705cb6..4cd5b07 100644 --- a/docs/rsgi.md +++ b/docs/rsgi.md @@ -18,20 +18,29 @@ The Python `oxyroute.app.App` class implements the async RSGI entry that Granian ## Lifespan (optional) -`App` defines no-op coroutines for servers that expect them. Implementations use `*args, **kwargs` so **Granian** (and any server that passes extra parameters to worker lifespan hooks) can call them without a `TypeError`: +Granian’s RSGI worker calls **sync** lifespan hooks with a **non-running** event loop: -- `async def __rsgi_init__(self, *args, **kwargs) -> None` — per-worker (or per-process) **startup** in the RSGI host -- `async def __rsgi_del__(self, *args, **kwargs) -> None` — **teardown** when the worker stops +```python +def __rsgi_init__(self, loop): + loop.run_until_complete(...) +``` + +OxyRoute’s base `App` implements that contract. Prefer overriding the async helpers: + +- **`async def on_startup(self) -> None`** — per-worker startup (DB pools, clients, …) +- **`async def on_shutdown(self) -> None`** — teardown (base closes the SQLx pool if any) + +The framework’s sync **`__rsgi_init__(loop)`** / **`__rsgi_del__(loop)`** call `loop.run_until_complete` on those coroutines. When called **without** a loop (tests / `TestClient`), they **return** the coroutine so callers can `await` it. -You can **override** these in a **subclass** of `App` to open DB pools, HTTP clients, `asyncio` primitives, etc. The default base implementation does nothing. +**Warning:** Do not override `__rsgi_init__` as `async def`. Under Granian the coroutine is never awaited (`coroutine was never awaited`), so pools never open. Override **`on_startup`** / **`on_shutdown`** instead. -Every `App` exposes **`app.state`**, a `types.SimpleNamespace` for attaching **per-process** objects. Use it in `__rsgi_init__` (or a factory) instead of ad hoc attributes on `self` if you want a single obvious place for shared services; it is the same not-shared-across-processes story as any other in-memory `App` data. +Every `App` exposes **`app.state`**, a `types.SimpleNamespace` for attaching **per-process** objects. Use it in `on_startup` (or a factory) instead of ad hoc attributes on `self` if you want a single obvious place for shared services; it is the same not-shared-across-processes story as any other in-memory `App` data. ### Workers and shared state (Granian) -- With **`granian --workers N`**, the server runs **N independent worker processes** (typical for CPU-bound HTTP). Each process loads your module, constructs your `app`, and may call `__rsgi_init__` **once per worker** (exact call pattern is defined by the server; see [Granian’s docs](https://github.com/emmett-framework/granian)). **In-memory** attributes you set in `__rsgi_init__` are **not** shared between workers: two requests may hit different processes and see different `self.foo`. +- With **`granian --workers N`**, the server runs **N independent worker processes** (typical for CPU-bound HTTP). Each process loads your module, constructs your `app`, and may call `__rsgi_init__` **once per worker** (exact call pattern is defined by the server; see [Granian’s docs](https://github.com/emmett-framework/granian)). **In-memory** attributes you set in `on_startup` are **not** shared between workers: two requests may hit different processes and see different `self.foo`. - If you use **a single worker** or run under **in-process** tests, one process is enough for a module-level or `self` cache for development only. -- For **user sessions, counts, or singletons** across the whole deployment, use **external** storage (Postgres, Redis, etc.); a DB **connection pool** created in `__rsgi_init__` is still a good pattern: one pool **per process**, many requests share connections inside that pool. +- For **user sessions, counts, or singletons** across the whole deployment, use **external** storage (Postgres, Redis, etc.); a DB **connection pool** created in `on_startup` is still a good pattern: one pool **per process**, many requests share connections inside that pool. ### Factory pattern @@ -51,9 +60,10 @@ app = create_app() ### Example in the repository - [`examples/rsgi_app.py`](../examples/rsgi_app.py) — minimal RSGI app -- [`examples/rsgi_lifespan_app.py`](../examples/rsgi_lifespan_app.py) — subclass with `__rsgi_init__` / `__rsgi_del__` and `ready_at` used from handlers +- [`examples/rsgi_lifespan_app.py`](../examples/rsgi_lifespan_app.py) — subclass with `on_startup` / `on_shutdown` and `ready_at` used from handlers ## See also - [Handlers](handlers.md) — what the Rust core passes into your functions - [Routing](routing.md) — how paths are matched +- [OpenAPI](openapi.md) — docs UI and enriched `/openapi.json` diff --git a/docs/usage.md b/docs/usage.md index 9d0abb1..40b1814 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -79,7 +79,7 @@ See [installation.md](installation.md) for troubleshooting native builds. ```python from oxyroute import App -app = App(title="My API", include_openapi=True) +app = App(title="My API", include_openapi=True, docs_ui="scalar") ``` Constructor options: @@ -88,6 +88,8 @@ Constructor options: |---|---:|---| | `title` | `"OxyRoute"` | Stored in the generated OpenAPI document. | | `include_openapi` | `True` | Serve built-in `GET` / `HEAD /openapi.json`. | +| `docs_ui` | `None` | `"scalar"` or `"swagger"` → mount interactive `GET /docs`. | +| `openapi_description` / `openapi_contact` / `openapi_servers` | `None` | Enrich OpenAPI `info` / `servers`. | Runtime methods: @@ -96,6 +98,8 @@ Runtime methods: | `app.freeze()` | Reject new route registrations and build a read-only routing snapshot. The app also auto-builds this snapshot on first request if you do not call `freeze()`. | | `app.set_openapi_served(False)` | Stop serving built-in `/openapi.json`; the in-memory document still exists. | | `app.openapi_json()` | Return the current OpenAPI JSON string even when serving is disabled. | +| `app.set_openapi_info(...)` | Set `info.description` / `contact` / `servers`. | +| `app.mount_docs(path, ui=...)` | Mount Scalar or Swagger UI at a custom path. | | `app.set_middleware(fn_or_none)` | Enable or disable one optional pre-route callback. | | `app.set_cors(config_or_none)` | Enable or disable CORS header merging. | | `app.set_security_headers(config_or_none)` | Enable or disable browser security header merging. | @@ -488,17 +492,18 @@ or `body_schema=` on `post`, `put`, and `patch` routes. ## Lifespan and per-worker state -Subclass `App` when you need per-worker setup/teardown: +Subclass `App` and override **`on_startup` / `on_shutdown`** (Granian calls sync +`__rsgi_init__(loop)` with a non-running loop — do not use `async def __rsgi_init__`): ```python from oxyroute import App class MyApp(App): - async def __rsgi_init__(self, *args, **kwargs) -> None: + async def on_startup(self) -> None: self.state.ready = True - async def __rsgi_del__(self, *args, **kwargs) -> None: + async def on_shutdown(self) -> None: self.state.ready = False @@ -506,7 +511,7 @@ app = MyApp() ``` `app.state` is a `types.SimpleNamespace`. It is per process, not shared between -Granian workers. +Granian workers. See [rsgi.md](rsgi.md). ## Recommended production shape diff --git a/examples/rsgi_lifespan_app.py b/examples/rsgi_lifespan_app.py index 6deed60..b3a914c 100644 --- a/examples/rsgi_lifespan_app.py +++ b/examples/rsgi_lifespan_app.py @@ -1,5 +1,5 @@ """ -Per-worker RSGI lifecycle: override ``__rsgi_init__`` / ``__rsgi_del__`` (issue #18). +Per-worker RSGI lifecycle: override ``on_startup`` / ``on_shutdown`` (issue #18 / #130). Run (from the repo root after an editable / wheel install):: @@ -8,7 +8,12 @@ ``examples/rsgi_app.py`` is the minimal app. This file shows **subclassing** ``App`` to open resources when the host starts a worker, using :attr:`oxyroute.app.App.state` and a :func:`concurrent.futures.ThreadPoolExecutor` (typical for blocking I/O in sync handlers; -use ``asyncio`` primitives in ``__rsgi_init__`` when your stack is natively async). +use ``asyncio`` primitives in ``on_startup`` when your stack is natively async). + +**Granian** calls sync ``__rsgi_init__(loop)`` / ``__rsgi_del__(loop)`` with a +**non-running** loop. The base ``App`` runs ``on_startup`` / ``on_shutdown`` via +``loop.run_until_complete``. Do **not** override ``__rsgi_init__`` as ``async def`` — +that coroutine is never awaited under Granian. In-memory data is **per OS process**; with ``granian --workers N`` each worker has its own object graph — use Redis, a DB pool, or a message bus for **cross-worker** or @@ -29,7 +34,7 @@ class LifespanApp(App): - """Example: attach per-process state when the RSGI worker calls ``__rsgi_init__``.""" + """Example: attach per-process state when the RSGI worker calls ``on_startup``.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -37,7 +42,7 @@ def __init__(self, *args, **kwargs): self.state.ready_at = None self.state.thread_pool = None - async def __rsgi_init__(self, *args, **kwargs) -> None: + async def on_startup(self) -> None: # ``asyncio`` primitive example (use from async callables you control). self.state.bg_limit = asyncio.Semaphore(8) # Thread pool: run blocking work via ``run_in_executor(self.state.thread_pool, ...)`` @@ -47,9 +52,8 @@ async def __rsgi_init__(self, *args, **kwargs) -> None: thread_name_prefix="rsgi", ) self.state.ready_at = time.time() - return None - async def __rsgi_del__(self, *args, **kwargs) -> None: + async def on_shutdown(self) -> None: pool = self.state.thread_pool if pool is not None: pool.shutdown(wait=True) @@ -57,7 +61,6 @@ async def __rsgi_del__(self, *args, **kwargs) -> None: self.state.thread_pool = None if hasattr(self.state, "bg_limit"): del self.state.bg_limit - return None app = LifespanApp(title="Lifespan example") diff --git a/oxyroute/app.py b/oxyroute/app.py index e9e7eec..8c3e027 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -7,6 +7,8 @@ from typing import Any, TypeVar from . import _oxyroute +from .docs_ui import docs_html, normalize_docs_ui +from .response import Response from .router import APIRouter, join_path F = TypeVar("F", bound=Callable[..., Any]) @@ -71,8 +73,8 @@ class App: the legacy ASGI bridge was removed in v0.3.0. ``state`` is an empty ``types.SimpleNamespace`` for per-process data; set fields in - ``__rsgi_init__`` or a factory, or on a subclass. In-memory data is not shared across - Granian worker processes. + ``on_startup`` / ``__rsgi_init__`` or a factory, or on a subclass. In-memory data is + not shared across Granian worker processes. """ def __init__( @@ -80,14 +82,28 @@ def __init__( title: str = "OxyRoute", *, include_openapi: bool = True, + docs_ui: str | None = None, + openapi_description: str | None = None, + openapi_contact: Mapping[str, Any] | None = None, + openapi_servers: list[Mapping[str, Any]] | None = None, access_log_hook: Callable[[Any, int, float, str], None] | None = None, ) -> None: self._app = _oxyroute.App(include_openapi=include_openapi) self._app.set_openapi_title(title) self.title = title self.access_log_hook = access_log_hook - # Per-process mutable bag for ``__rsgi_init__`` / factory setup (DB pool, clients, …). + # Per-process mutable bag for ``on_startup`` / factory setup (DB pool, clients, …). self.state: SimpleNamespace = SimpleNamespace() + self._docs_ui: str | None = normalize_docs_ui(docs_ui) + self._docs_mounted: bool = False + if openapi_description is not None or openapi_contact is not None or openapi_servers is not None: + self.set_openapi_info( + description=openapi_description, + contact=openapi_contact, + servers=openapi_servers, + ) + if self._docs_ui is not None: + self.mount_docs("/docs", ui=self._docs_ui) def freeze(self) -> None: """After ``freeze()``, no more route registration (matches Rust app state).""" @@ -97,17 +113,61 @@ def set_openapi_served(self, enabled: bool) -> None: """Enable or disable the built-in ``GET /openapi.json`` route.""" self._app.set_openapi_served(enabled) + def set_openapi_info( + self, + *, + description: str | None = None, + contact: Mapping[str, Any] | None = None, + servers: list[Mapping[str, Any]] | None = None, + ) -> None: + """ + Enrich the OpenAPI document ``info`` and optional ``servers`` list. + + ``contact`` is an OpenAPI contact object (e.g. ``{"name": "…", "email": "…"}``). + ``servers`` is a list of ``{"url": "…", "description": "…"}`` objects. + """ + contact_json = json.dumps(dict(contact)) if contact is not None else None + servers_json = json.dumps([dict(s) for s in servers]) if servers is not None else None + self._app.set_openapi_info(description, contact_json, servers_json) + + def mount_docs( + self, + path: str = "/docs", + *, + ui: str = "scalar", + openapi_url: str = "/openapi.json", + ) -> None: + """ + Register ``GET path`` serving Scalar or Swagger UI against ``openapi_url``. + + UI assets load from a public CDN; set CSP ``script-src`` / ``style-src`` accordingly + (or disable security-header presets that block CDN scripts on the docs route). + """ + ui_n = normalize_docs_ui(ui) + if ui_n is None: + raise ValueError("ui is required") + path = path.rstrip("/") or "/docs" + html = docs_html(ui=ui_n, title=self.title, openapi_url=openapi_url) + headers = {"content-type": "text/html; charset=utf-8"} + + def _docs() -> Response: + return Response(body=html, status=200, headers=headers) + + self.get(path)(_docs) + self._docs_ui = ui_n + self._docs_mounted = True + async def setup_database(self, url: str, max_connections: int = 10) -> None: """ Connect to a PostgreSQL database and store the pool in the Rust hot path. - Must be awaited (e.g. inside ``__rsgi_init__``). + Must be awaited (e.g. inside ``on_startup``). """ await self._app.setup_database(url, max_connections) async def close_database(self) -> None: """ Close the global PostgreSQL connection pool. - Must be awaited (e.g. inside ``__rsgi_del__``). + Must be awaited (e.g. inside ``on_shutdown``). """ await self._app.close_database() @@ -192,6 +252,7 @@ def get( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "GET", @@ -206,6 +267,7 @@ def get( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def post( @@ -224,6 +286,7 @@ def post( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "POST", @@ -240,6 +303,7 @@ def post( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def put( @@ -258,6 +322,7 @@ def put( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "PUT", @@ -274,6 +339,7 @@ def put( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def patch( @@ -292,6 +358,7 @@ def patch( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "PATCH", @@ -308,6 +375,7 @@ def patch( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def delete( @@ -322,6 +390,7 @@ def delete( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "DELETE", @@ -336,6 +405,7 @@ def delete( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def websocket(self, path: str) -> Callable[[F], F]: @@ -368,6 +438,7 @@ def options( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._route( "OPTIONS", @@ -382,6 +453,7 @@ def options( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def _route( @@ -401,6 +473,7 @@ def _route( jwt_cookie: str | None = None, body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: dlist = _norm_dependencies(dependencies) @@ -431,22 +504,54 @@ def wrap(handler: F) -> F: jwt_cookie, body_schema_json, body_model, + tags, ) return handler return wrap - async def __rsgi_init__(self, *args: Any, **kwargs: Any) -> None: + @staticmethod + def _run_lifespan(coro: Any, loop: Any | None) -> Any: + """ + Granian calls sync ``__rsgi_init__(loop)`` / ``__rsgi_del__(loop)`` with a + **non-running** event loop — use ``run_until_complete``. TestClient and + ``await app.__rsgi_init__()`` pass no loop and receive the coroutine. """ - RSGI worker startup (no-op in the base class). Subclass to open pools/clients; see - ``docs/rsgi.md`` (Lifespan) and ``examples/rsgi_lifespan_app.py``. + if loop is not None and hasattr(loop, "run_until_complete"): + try: + running = bool(loop.is_running()) + except Exception: + running = False + if not running: + return loop.run_until_complete(coro) + return coro + + async def on_startup(self) -> None: + """ + Per-worker async startup. Override in a subclass; the base class runs this from + sync :meth:`__rsgi_init__` under Granian. """ return None - async def __rsgi_del__(self, *args: Any, **kwargs: Any) -> None: - """RSGI worker teardown. Closes the global connection pool if it exists.""" + async def on_shutdown(self) -> None: + """Per-worker async teardown. Closes the global connection pool if it exists.""" await self.close_database() + def __rsgi_init__(self, loop: Any | None = None, *args: Any, **kwargs: Any) -> Any: + """ + RSGI worker startup (Granian-compatible). + + Granian invokes this as a **sync** method with the worker ``loop`` (not running) + and expects ``loop.run_until_complete(...)``. Prefer overriding :meth:`on_startup` + instead of this method. When called with no ``loop`` (tests), returns the + ``on_startup`` coroutine for the caller to await. + """ + return self._run_lifespan(self.on_startup(), loop) + + def __rsgi_del__(self, loop: Any | None = None, *args: Any, **kwargs: Any) -> Any: + """RSGI worker teardown; see :meth:`__rsgi_init__` / :meth:`on_shutdown`.""" + return self._run_lifespan(self.on_shutdown(), loop) + async def __rsgi__(self, scope: Any, protocol: Any) -> Any: """ Granian awaits this coroutine. Native ``handle_rsgi`` may return ``None`` immediately diff --git a/oxyroute/docs_ui.py b/oxyroute/docs_ui.py new file mode 100644 index 0000000..949344d --- /dev/null +++ b/oxyroute/docs_ui.py @@ -0,0 +1,81 @@ +"""Built-in OpenAPI docs UIs (Scalar / Swagger UI) loaded from CDN.""" + +from __future__ import annotations + +from html import escape + +__all__ = ["docs_html", "normalize_docs_ui"] + +_VALID = frozenset({"scalar", "swagger"}) + + +def normalize_docs_ui(ui: str | None) -> str | None: + if ui is None: + return None + v = ui.strip().lower() + if v not in _VALID: + raise ValueError(f"docs_ui must be one of {sorted(_VALID)} or None, got {ui!r}") + return v + + +def docs_html( + *, + ui: str, + title: str, + openapi_url: str = "/openapi.json", +) -> str: + """Return HTML for Scalar or Swagger UI pointing at ``openapi_url``.""" + ui_n = normalize_docs_ui(ui) + if ui_n is None: + raise ValueError("docs_ui is required") + safe_title = escape(title) + safe_url = escape(openapi_url, quote=True) + if ui_n == "scalar": + return _scalar_html(safe_title, safe_url) + return _swagger_html(safe_title, safe_url) + + +def _scalar_html(title: str, openapi_url: str) -> str: + return f""" + + + + + {title} — API docs + + + + + + +""" + + +def _swagger_html(title: str, openapi_url: str) -> str: + return f""" + + + + + {title} — API docs + + + + +
+ + + + +""" diff --git a/oxyroute/router.py b/oxyroute/router.py index 7e19805..64ee6ed 100644 --- a/oxyroute/router.py +++ b/oxyroute/router.py @@ -50,7 +50,9 @@ def __init__(self) -> None: def _reg(self, method: str, path: str, **opts: Any) -> Callable[[F], F]: def dec(handler: F) -> F: - self._routes.append((method, path, handler, dict(opts))) + # Drop ``None`` so ``include_router(..., tags=[...])`` defaults are not wiped. + cleaned = {k: v for k, v in opts.items() if v is not None} + self._routes.append((method, path, handler, cleaned)) return handler return dec @@ -88,6 +90,7 @@ def get( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "GET", @@ -100,6 +103,7 @@ def get( jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, dependencies=dependencies, + tags=tags, ) def post( @@ -118,6 +122,7 @@ def post( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "POST", @@ -134,6 +139,7 @@ def post( body_model=body_model, body_schema=body_schema, dependencies=dependencies, + tags=tags, ) def put( @@ -152,6 +158,7 @@ def put( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "PUT", @@ -168,6 +175,7 @@ def put( body_model=body_model, body_schema=body_schema, dependencies=dependencies, + tags=tags, ) def patch( @@ -186,6 +194,7 @@ def patch( body_model: Any | None = None, body_schema: Mapping[str, Any] | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "PATCH", @@ -202,6 +211,7 @@ def patch( body_model=body_model, body_schema=body_schema, dependencies=dependencies, + tags=tags, ) def delete( @@ -216,6 +226,7 @@ def delete( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "DELETE", @@ -228,6 +239,7 @@ def delete( jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, dependencies=dependencies, + tags=tags, ) def options( @@ -242,6 +254,7 @@ def options( jwt_leeway: int | None = None, jwt_cookie: str | None = None, dependencies: list[tuple[str, Dep]] | None = None, + tags: list[str] | None = None, ) -> Callable[[F], F]: return self._reg( "OPTIONS", @@ -254,4 +267,5 @@ def options( jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, dependencies=dependencies, + tags=tags, ) diff --git a/src/lib.rs b/src/lib.rs index 06fbb56..2a302bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,22 +107,87 @@ pub struct App { } impl App { + /// Convert matchit `:name` / `*rest` templates to OpenAPI `{name}` / `{rest}` and + /// collect path parameter objects. + fn openapi_path_and_params(path: &str) -> (String, Vec) { + let mut out = String::with_capacity(path.len() + 8); + let mut params = Vec::new(); + let chars: Vec = path.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == ':' || c == '*' { + i += 1; + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let name: String = chars[start..i].iter().collect(); + if !name.is_empty() { + out.push('{'); + out.push_str(&name); + out.push('}'); + params.push(json!({ + "name": name, + "in": "path", + "required": true, + "schema": { "type": "string" } + })); + } + } else { + out.push(c); + i += 1; + } + } + (out, params) + } + + fn openapi_ensure_bearer_auth(oa: &mut serde_json::Value) { + let Some(root) = oa.as_object_mut() else { + return; + }; + let components = root + .entry("components") + .or_insert_with(|| json!({})); + let Some(comp) = components.as_object_mut() else { + return; + }; + let schemes = comp + .entry("securitySchemes") + .or_insert_with(|| json!({})); + if let Some(s) = schemes.as_object_mut() { + s.entry("bearerAuth").or_insert_with(|| { + json!({ + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }) + }); + } + } + fn openapi_add_path( oa: &mut serde_json::Value, method: &str, path: &str, op_id: &str, request_schema: Option, + require_jwt: bool, + tags: Option>, ) { + let (oa_path, path_params) = Self::openapi_path_and_params(path); + if require_jwt { + Self::openapi_ensure_bearer_auth(oa); + } if let Some(paths) = oa .as_object_mut() .and_then(|m| m.get_mut("paths")) .and_then(|p| p.as_object_mut()) { let method_lc = method.to_lowercase(); - let path_entry = paths.entry(path).or_insert_with(|| json!({})); + let path_entry = paths.entry(oa_path).or_insert_with(|| json!({})); if let Some(obj) = path_entry.as_object_mut() { - let op = if let Some(schema) = request_schema { + let mut op = if let Some(schema) = request_schema { json!({ "summary": op_id, "operationId": op_id, @@ -143,6 +208,22 @@ impl App { "responses": { "200": { "description": "OK" } } }) }; + if let Some(op_obj) = op.as_object_mut() { + if !path_params.is_empty() { + op_obj.insert("parameters".to_string(), json!(path_params)); + } + if require_jwt { + op_obj.insert( + "security".to_string(), + json!([{ "bearerAuth": [] }]), + ); + } + if let Some(t) = tags { + if !t.is_empty() { + op_obj.insert("tags".to_string(), json!(t)); + } + } + } obj.insert(method_lc, op); } } @@ -163,7 +244,7 @@ impl App { /// Paths use **matchit 0.7** style: `/user/:id`. Pass `dependencies=[("x", get_x), ...]`. #[pyo3( - signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, read_form_body=false, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None, body_model=None) + signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, read_form_body=false, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None, body_model=None, tags=None) )] #[allow(clippy::too_many_arguments)] fn add_route( @@ -184,6 +265,7 @@ impl App { jwt_cookie: Option, body_schema_json: Option, body_model: Option>, + tags: Option>, ) -> PyResult<()> { { let st = self.state.read(); @@ -281,9 +363,27 @@ impl App { pyo3::exceptions::PyValueError::new_err(format!("invalid body_schema JSON: {e}")) })?), }; + let tag_list: Option> = if let Some(list) = tags { + let n = list.len(); + let mut v = Vec::with_capacity(n); + for i in 0..n { + v.push(list.get_item(i)?.extract()?); + } + Some(v) + } else { + None + }; { let mut oa = st.openapi.lock(); - App::openapi_add_path(&mut oa.0, &method, &path, &op_id, request_schema); + App::openapi_add_path( + &mut oa.0, + &method, + &path, + &op_id, + request_schema, + require_jwt, + tag_list, + ); oa.1 = None; } { @@ -361,6 +461,48 @@ impl App { Ok(()) } + /// Enrich OpenAPI ``info`` / ``servers``. Pass JSON strings for ``contact`` and ``servers``. + #[pyo3(signature = (description=None, contact_json=None, servers_json=None))] + fn set_openapi_info( + &self, + description: Option, + contact_json: Option, + servers_json: Option, + ) -> PyResult<()> { + let st = self.state.read(); + let mut oa = st.openapi.lock(); + let Some(root) = oa.0.as_object_mut() else { + return Ok(()); + }; + if let Some(desc) = description { + if let Some(info) = root + .get_mut("info") + .and_then(|i| i.as_object_mut()) + { + info.insert("description".to_string(), json!(desc)); + } + } + if let Some(raw) = contact_json { + let contact: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid contact JSON: {e}")) + })?; + if let Some(info) = root + .get_mut("info") + .and_then(|i| i.as_object_mut()) + { + info.insert("contact".to_string(), contact); + } + } + if let Some(raw) = servers_json { + let servers: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid servers JSON: {e}")) + })?; + root.insert("servers".to_string(), servers); + } + oa.1 = None; + Ok(()) + } + /// Single optional pre-route hook. Return ``None`` to continue; otherwise the return value /// is mapped like a route handler (e.g. :class:`oxyroute.Response`, ``dict`` with ``status`` / ``body`` / ``headers``). diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 8aaf4b2..9a1446c 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -3,7 +3,7 @@ import httpx import pytest -from oxyroute import App +from oxyroute import APIRouter, App from oxyroute.testing import asgi_test_app @@ -16,8 +16,19 @@ def list_items() -> str: s = app.openapi_json() assert "paths" in s - assert "/items/:i" in s + assert "/items/{i}" in s + assert "/items/:i" not in s assert "T" in s + doc = json.loads(s) + params = doc["paths"]["/items/{i}"]["get"]["parameters"] + assert params == [ + { + "name": "i", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ] def test_openapi_includes_patch_lowercase() -> None: @@ -31,6 +42,106 @@ def m() -> str: assert doc["paths"]["/m"]["patch"]["operationId"] == "m" +def test_openapi_jwt_security_scheme() -> None: + app = App() + + @app.get("/public") + def public() -> str: + return "ok" + + @app.get("/secret", require_jwt=True, jwt_secret="test-secret-key") + def secret(claims: dict) -> str: + return "ok" + + doc = json.loads(app.openapi_json()) + schemes = doc["components"]["securitySchemes"] + assert schemes["bearerAuth"]["type"] == "http" + assert schemes["bearerAuth"]["scheme"] == "bearer" + assert schemes["bearerAuth"]["bearerFormat"] == "JWT" + assert doc["paths"]["/secret"]["get"]["security"] == [{"bearerAuth": []}] + assert "security" not in doc["paths"]["/public"]["get"] + + +def test_openapi_tags_from_route_and_include_router() -> None: + r = APIRouter() + + @r.get("/a", tags=["alpha"]) + def a() -> str: + return "a" + + app = App() + app.include_router(r, prefix="/api", tags=["shared"]) + + @app.get("/b", tags=["beta"]) + def b() -> str: + return "b" + + doc = json.loads(app.openapi_json()) + # include_router merges defaults then per-route opts — route tags win over defaults + assert doc["paths"]["/api/a"]["get"]["tags"] == ["alpha"] + assert doc["paths"]["/b"]["get"]["tags"] == ["beta"] + + +def test_openapi_include_router_default_tags() -> None: + r = APIRouter() + + @r.get("/x") + def x() -> str: + return "x" + + app = App() + app.include_router(r, prefix="/v1", tags=["v1"]) + doc = json.loads(app.openapi_json()) + assert doc["paths"]["/v1/x"]["get"]["tags"] == ["v1"] + + +def test_openapi_set_info_and_servers() -> None: + app = App( + title="API", + openapi_description="Demo", + openapi_contact={"name": "Ops", "email": "ops@example.com"}, + openapi_servers=[{"url": "https://api.example.com", "description": "prod"}], + ) + doc = json.loads(app.openapi_json()) + assert doc["info"]["description"] == "Demo" + assert doc["info"]["contact"]["email"] == "ops@example.com" + assert doc["servers"][0]["url"] == "https://api.example.com" + + +def test_docs_ui_scalar_returns_html() -> None: + app = App(title="DocsApp", docs_ui="scalar") + + @app.get("/ping") + def ping() -> str: + return "pong" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/docs") + assert r.status_code == 200 + assert "text/html" in r.headers.get("content-type", "") + assert "/openapi.json" in r.text + assert "scalar" in r.text.lower() or "api-reference" in r.text + + asyncio.run(_run()) + + +def test_docs_ui_swagger_via_mount_docs() -> None: + app = App(title="Swag") + app.mount_docs("/swagger", ui="swagger") + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/swagger") + assert r.status_code == 200 + assert "text/html" in r.headers.get("content-type", "") + assert "swagger-ui" in r.text.lower() + + asyncio.run(_run()) + + def test_openapi_serving_off_constructor_get_and_head_404_openapi_json_still_filled() -> None: app = App(title="S", include_openapi=False) diff --git a/tests/test_rsgi_lifespan.py b/tests/test_rsgi_lifespan.py index 9776c05..a24c6f6 100644 --- a/tests/test_rsgi_lifespan.py +++ b/tests/test_rsgi_lifespan.py @@ -1,4 +1,4 @@ -"""RSGI lifespan hooks: subclassing ``App`` (issue #18).""" +"""RSGI lifespan hooks: ``on_startup`` / Granian-compatible sync init (issue #18 / #130).""" from __future__ import annotations @@ -18,13 +18,45 @@ def test_app_state_is_simple_namespace() -> None: def test_base_rsgi_init_and_del_are_noop() -> None: async def _go() -> None: a = App() + # No-arg: returns coroutine (TestClient / await style). await a.__rsgi_init__() await a.__rsgi_del__() asyncio.run(_go()) -def test_subclass_rsgi_init_can_set_state() -> None: +def test_on_startup_sets_state() -> None: + class WorkerApp(App): + async def on_startup(self) -> None: + self.marker = 7 + + async def _go() -> None: + a = WorkerApp() + assert not hasattr(a, "marker") + await a.__rsgi_init__() + assert a.marker == 7 + + asyncio.run(_go()) + + +def test_granian_style_sync_init_with_non_running_loop() -> None: + class WorkerApp(App): + async def on_startup(self) -> None: + self.state.ready = True + + a = WorkerApp() + loop = asyncio.new_event_loop() + try: + # Granian: sync call with a non-running loop. + result = a.__rsgi_init__(loop) + assert result is None + assert a.state.ready is True + a.__rsgi_del__(loop) + finally: + loop.close() + + +def test_subclass_legacy_async_rsgi_init_still_awaitable() -> None: class WorkerApp(App): async def __rsgi_init__(self, *args, **kwargs) -> None: self.marker = 7 @@ -32,7 +64,6 @@ async def __rsgi_init__(self, *args, **kwargs) -> None: async def _go() -> None: a = WorkerApp() - assert not hasattr(a, "marker") await a.__rsgi_init__() assert a.marker == 7 From fe663756dd078abe5baccd94569ec8b4d6e26eed Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 20 Jul 2026 13:13:12 +0300 Subject: [PATCH 2/3] style: ruff format oxyroute/app.py --- oxyroute/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/oxyroute/app.py b/oxyroute/app.py index 8c3e027..0a72ce7 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -96,7 +96,11 @@ def __init__( self.state: SimpleNamespace = SimpleNamespace() self._docs_ui: str | None = normalize_docs_ui(docs_ui) self._docs_mounted: bool = False - if openapi_description is not None or openapi_contact is not None or openapi_servers is not None: + if ( + openapi_description is not None + or openapi_contact is not None + or openapi_servers is not None + ): self.set_openapi_info( description=openapi_description, contact=openapi_contact, From acd8125d79716a22100209fad238d0ea96670d15 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 20 Jul 2026 13:19:13 +0300 Subject: [PATCH 3/3] style: cargo fmt lib.rs and state.rs --- src/lib.rs | 23 +++++------------------ src/state.rs | 9 ++++++++- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2a302bf..c21308b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -146,15 +146,11 @@ impl App { let Some(root) = oa.as_object_mut() else { return; }; - let components = root - .entry("components") - .or_insert_with(|| json!({})); + let components = root.entry("components").or_insert_with(|| json!({})); let Some(comp) = components.as_object_mut() else { return; }; - let schemes = comp - .entry("securitySchemes") - .or_insert_with(|| json!({})); + let schemes = comp.entry("securitySchemes").or_insert_with(|| json!({})); if let Some(s) = schemes.as_object_mut() { s.entry("bearerAuth").or_insert_with(|| { json!({ @@ -213,10 +209,7 @@ impl App { op_obj.insert("parameters".to_string(), json!(path_params)); } if require_jwt { - op_obj.insert( - "security".to_string(), - json!([{ "bearerAuth": [] }]), - ); + op_obj.insert("security".to_string(), json!([{ "bearerAuth": [] }])); } if let Some(t) = tags { if !t.is_empty() { @@ -475,10 +468,7 @@ impl App { return Ok(()); }; if let Some(desc) = description { - if let Some(info) = root - .get_mut("info") - .and_then(|i| i.as_object_mut()) - { + if let Some(info) = root.get_mut("info").and_then(|i| i.as_object_mut()) { info.insert("description".to_string(), json!(desc)); } } @@ -486,10 +476,7 @@ impl App { let contact: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("invalid contact JSON: {e}")) })?; - if let Some(info) = root - .get_mut("info") - .and_then(|i| i.as_object_mut()) - { + if let Some(info) = root.get_mut("info").and_then(|i| i.as_object_mut()) { info.insert("contact".to_string(), contact); } } diff --git a/src/state.rs b/src/state.rs index 9964d67..cb63c1e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -396,7 +396,14 @@ mod tests { assert_eq!(pre, post); let inner = pre.expect("match"); assert_eq!(inner.0, 7); - assert_eq!(inner.1.iter().find(|(k, _)| k == "id").map(|(_, v)| v.as_str()), Some("5")); + assert_eq!( + inner + .1 + .iter() + .find(|(k, _)| k == "id") + .map(|(_, v)| v.as_str()), + Some("5") + ); } #[test]