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
4 changes: 2 additions & 2 deletions .github/ISSUE_BACKLOG/PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
|---|------|-----------|
| 8 | [08.md](bodies/08.md) | **JWK / JWKS** — [GitHub #8](https://github.com/QueryaHub/OxyRoute/issues/8). |
| 9 | [09.md](bodies/09.md) | **OpenAPI depth** (optional) — `$ref` / `$defs`. |
| 21 | [21.md](bodies/21.md) | **Sub-routers** — [GitHub #46](https://github.com/QueryaHub/OxyRoute/issues/46). |
| 24 | [24.md](bodies/24.md) | **CORS helper** — [GitHub #49](https://github.com/QueryaHub/OxyRoute/issues/49). |
| 28 | [28.md](bodies/28.md) | **CSRF** (optional) — [GitHub #53](https://github.com/QueryaHub/OxyRoute/issues/53). |
| 29 | [29.md](bodies/29.md) | **Security headers preset** — [GitHub #54](https://github.com/QueryaHub/OxyRoute/issues/54). |
Expand All @@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
- **Lifespan / `app.state`:** 18 (see `App.state`, `examples/rsgi_lifespan_app.py`, `docs/rsgi.md`)
- **Form bodies:** 22 / [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — `read_form_body`, `form` / `files` kwargs, `docs/handlers.md`
- **HTTPException:** 23 / [#48](https://github.com/QueryaHub/OxyRoute/issues/48) — `oxyroute.exceptions`, `docs/handlers.md` (per-type `register_exception_handler` not in scope)
- **Sub-routers:** 21 / [#46](https://github.com/QueryaHub/OxyRoute/issues/46) — `APIRouter`, `include_router`, `docs/routing.md`

## Roadmap phasing (summary)

1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible).
2. **P1:** 8, 46, 49, 53, 54; **#48** done — `HTTPException`; 9 as polish.
2. **P1:** 8, 49, 53, 54; **#48** / **#46** done; 9 as polish.
3. **Research:** 50, 51, 52 — as capacity allows.

[← Back to README](README.md)
2 changes: 1 addition & 1 deletion docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

| Тема | Зазор | Комментарий |
|------|--------|-------------|
| **Sub-routers / `include_router`** | Нет | Один плоский `App`; нет префиксов и вложенных без ручного дублирования путей. |
| **Sub-routers / `include_router`** | Частично | `APIRouter`, `App.include_router` — [routing](routing.md), [#46](https://github.com/QueryaHub/OxyRoute/issues/46). |
| **`Mount` / static files** | Нет | Отдача `/static` из каталога — обычно отдельный слой (nginx) или Starlette StaticFiles. |
| **Host-based routing** | Нет | |
| **Глобальные exception handlers** | Частично | `HTTPException` → нужный статус/JSON (см. [handlers](handlers.md)); **нет** `register_exception_handler` / иерархии как в FastAPI — [#48](https://github.com/QueryaHub/OxyRoute/issues/48). |
Expand Down
11 changes: 11 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ Each is a decorator that registers a route and returns the handler unchanged (so

If OpenAPI is enabled, route registration also updates a minimal OpenAPI document. See [openapi.md](openapi.md).

## Sub-routers (`APIRouter` + `include_router`)

For shared path prefixes (for example ``/api/v1/...``), build routes on an **`APIRouter`**, then mount them on the main **`App`** with **`include_router(router, prefix=...)`**. Optional keyword arguments to **`include_router`** are merged with each route’s own options; **per-route** options win on key conflicts (same as FastAPI’s `include_router` defaults).

- **`oxyroute.APIRouter`**: the same `get` / `post` / `put` / `patch` / `delete` / `options` decorators as `App`, but they only *record* routes.
- **`App.include_router(router, prefix="")`**: registers all recorded routes on the native `App` with `join_path(prefix, route_path)` (leading slashes normalized).
- **Nesting:** `parent_router.include_router(child_router, prefix="…")` flattens child routes with that prefix, then the parent is mounted on `App` as usual.
- **Duplicate** path + method: still rejected at register time (same as registering twice on `App`).

Example: [examples/routers_include_app.py](../examples/routers_include_app.py)

## See also

- [Handlers](handlers.md) — how matched parameters become keyword arguments
Expand Down
32 changes: 32 additions & 0 deletions examples/routers_include_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
Sub-routers and ``include_router`` (issue #46).

Run from the project root (after an editable or wheel install)::

granian --interface rsgi examples.routers_include_app:app

See `docs/routing.md` and https://github.com/QueryaHub/OxyRoute/issues/46
"""

from oxyroute import APIRouter, App

api = APIRouter()


@api.get("/ping")
def api_ping() -> str:
return "pong"


@api.get("/version")
def api_version() -> str:
return "1"


app = App(title="Include router example")
app.include_router(api, prefix="/v1") # => GET /v1/ping, /v1/version


@app.get("/")
def root() -> str:
return "ok"
11 changes: 10 additions & 1 deletion oxyroute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
from oxyroute.app import App, Depends
from oxyroute.exceptions import HTTPException
from oxyroute.response import Response
from oxyroute.router import APIRouter

__all__ = ["App", "Depends", "HTTPException", "Response", "__version__", "decode_jwt_hs"]
__all__ = [
"APIRouter",
"App",
"Depends",
"HTTPException",
"Response",
"__version__",
"decode_jwt_hs",
]
__version__ = "0.1.0"
28 changes: 28 additions & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from __future__ import annotations

import inspect
import json
from collections.abc import Callable, Mapping
from types import SimpleNamespace
from typing import Any, TypeVar

from . import _oxyroute
from .asgi import build_asgi_caller
from .router import APIRouter, join_path

F = TypeVar("F", bound=Callable[..., Any])
Dep = Callable[..., Any] | _oxyroute.PyDepends
Expand Down Expand Up @@ -57,6 +59,32 @@ 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 include_router(
self,
router: APIRouter,
prefix: str = "",
**defaults: Any,
) -> None:
"""
Mount routes from an :class:`APIRouter` on this app with an optional path prefix.
Per-route options win over ``**defaults`` (same keys as the ``get`` / ``post`` / … decorators).
"""
regmap: dict[str, Callable[..., Any]] = {
"GET": App.get,
"POST": App.post,
"PUT": App.put,
"PATCH": App.patch,
"DELETE": App.delete,
"OPTIONS": App.options,
}
for method, rel, handler, opts in router._routes:
merged: dict[str, Any] = {**defaults, **opts}
full = join_path(prefix, rel)
reg = regmap[method]
allowed = {p for p in inspect.signature(reg).parameters if p not in ("self", "path")}
kw: dict[str, Any] = {k: v for k, v in merged.items() if k in allowed}
reg(self, full, **kw)(handler)

def set_middleware(self, handler: Callable[..., Any] | None) -> None:
"""
One optional pre-route callback ``(scope, protocol)`` — return ``None`` to pass through.
Expand Down
248 changes: 248 additions & 0 deletions oxyroute/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""
Composable sub-routers (issue #46): collect routes, then :meth:`oxyroute.app.App.include_router`.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping
from typing import Any, TypeVar

from . import _oxyroute

F = TypeVar("F", bound=Callable[..., Any])
Dep = Callable[..., Any] | _oxyroute.PyDepends


def join_path(prefix: str, path: str) -> str:
"""
Join a URL path prefix and a route path. Both Granian and matchit use a leading ``/``.

* ``"/v1"`` + ``"/a"`` → ``"/v1/a"``
* ``""`` + ``"/a"`` → ``"/a"``
* ``"/v1"`` + ``"/"`` → ``"/v1/"``
"""
path = path.strip() if path else ""
if not path:
path = "/"
elif not path.startswith("/"):
path = "/" + path
p = (prefix or "").strip()
if not p:
return path
if not p.startswith("/"):
p = "/" + p
p = p.rstrip("/")
if path == "/":
return f"{p}/"
return f"{p}{path}"


class APIRouter:
"""
Register routes the same way as :class:`oxyroute.app.App`, then attach with
``app.include_router(router, prefix="/api/v1")``. Nested routers: ``router_a.include_router(router_b, prefix="…")``.
"""

__slots__ = ("_routes",)

def __init__(self) -> None:
self._routes: list[tuple[str, str, Any, dict[str, Any]]] = []

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)))
return handler

return dec

def include_router(
self,
router: APIRouter,
prefix: str = "",
**defaults: Any,
) -> None:
"""Copy routes from ``router`` into this router, prefixing paths."""
for method, rel, handler, opts in router._routes:
merged: dict[str, Any] = {**defaults, **opts}
full = join_path(prefix, rel)
self._routes.append((method, full, handler, merged))

def get(
self,
path: str,
*,
require_jwt: bool = False,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._reg(
"GET",
path,
require_jwt=require_jwt,
jwt_secret=jwt_secret,
algorithms=algorithms,
jwt_issuer=jwt_issuer,
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
dependencies=dependencies,
)

def post(
self,
path: str,
*,
require_jwt: bool = False,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
read_json_body: bool = True,
read_form_body: bool = False,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
body_schema: Mapping[str, Any] | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._reg(
"POST",
path,
require_jwt=require_jwt,
jwt_secret=jwt_secret,
algorithms=algorithms,
read_json_body=read_json_body,
read_form_body=read_form_body,
jwt_issuer=jwt_issuer,
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
body_model=body_model,
body_schema=body_schema,
dependencies=dependencies,
)

def put(
self,
path: str,
*,
require_jwt: bool = False,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
read_json_body: bool = True,
read_form_body: bool = False,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
body_schema: Mapping[str, Any] | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._reg(
"PUT",
path,
require_jwt=require_jwt,
jwt_secret=jwt_secret,
algorithms=algorithms,
read_json_body=read_json_body,
read_form_body=read_form_body,
jwt_issuer=jwt_issuer,
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
body_model=body_model,
body_schema=body_schema,
dependencies=dependencies,
)

def patch(
self,
path: str,
*,
require_jwt: bool = False,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
read_json_body: bool = True,
read_form_body: bool = False,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
body_schema: Mapping[str, Any] | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._reg(
"PATCH",
path,
require_jwt=require_jwt,
jwt_secret=jwt_secret,
algorithms=algorithms,
read_json_body=read_json_body,
read_form_body=read_form_body,
jwt_issuer=jwt_issuer,
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
body_model=body_model,
body_schema=body_schema,
dependencies=dependencies,
)

def delete(
self,
path: str,
*,
require_jwt: bool = False,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._reg(
"DELETE",
path,
require_jwt=require_jwt,
jwt_secret=jwt_secret,
algorithms=algorithms,
jwt_issuer=jwt_issuer,
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
dependencies=dependencies,
)

def options(
self,
path: str,
*,
require_jwt: bool = False,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._reg(
"OPTIONS",
path,
require_jwt=require_jwt,
jwt_secret=jwt_secret,
algorithms=algorithms,
jwt_issuer=jwt_issuer,
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
dependencies=dependencies,
)
Loading
Loading