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..0a72ce7 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,32 @@ 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 +117,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 +256,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 +271,7 @@ def get( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def post( @@ -224,6 +290,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 +307,7 @@ def post( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def put( @@ -258,6 +326,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 +343,7 @@ def put( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def patch( @@ -292,6 +362,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 +379,7 @@ def patch( jwt_cookie=jwt_cookie, body_model=body_model, body_schema=body_schema, + tags=tags, ) def delete( @@ -322,6 +394,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 +409,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 +442,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 +457,7 @@ def options( jwt_audience=jwt_audience, jwt_leeway=jwt_leeway, jwt_cookie=jwt_cookie, + tags=tags, ) def _route( @@ -401,6 +477,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 +508,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""" + +
+ + +