Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
52 changes: 46 additions & 6 deletions docs/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -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)
26 changes: 18 additions & 8 deletions docs/rsgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`
15 changes: 10 additions & 5 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:

Expand All @@ -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. |
Expand Down Expand Up @@ -488,25 +492,26 @@ 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


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

Expand Down
17 changes: 10 additions & 7 deletions examples/rsgi_lifespan_app.py
Original file line number Diff line number Diff line change
@@ -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)::

Expand All @@ -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
Expand All @@ -29,15 +34,15 @@


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)
# ``state`` is a :class:`types.SimpleNamespace` on the base :class:`App`.
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, ...)``
Expand All @@ -47,17 +52,15 @@ 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)
self.state.ready_at = None
self.state.thread_pool = None
if hasattr(self.state, "bg_limit"):
del self.state.bg_limit
return None


app = LifespanApp(title="Lifespan example")
Expand Down
Loading
Loading