From e9ba65e5f06c085fec05d4c708d5147001a10ba9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 23:52:22 +0300 Subject: [PATCH] feat: APIRouter and App.include_router (#46) - Add oxyroute.router: APIRouter (same route decorators as App), join_path, nested APIRouter.include_router - App.include_router: merge default kwargs, filter by method signature, register on native app + OpenAPI - tests, examples/routers_include_app.py, docs/routing.md; PRIORITIES + feature.md Closes #46 --- .github/ISSUE_BACKLOG/PRIORITIES.md | 4 +- docs/feature.md | 2 +- docs/routing.md | 11 ++ examples/routers_include_app.py | 32 ++++ oxyroute/__init__.py | 11 +- oxyroute/app.py | 28 ++++ oxyroute/router.py | 248 ++++++++++++++++++++++++++++ tests/test_api_router.py | 107 ++++++++++++ 8 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 examples/routers_include_app.py create mode 100644 oxyroute/router.py create mode 100644 tests/test_api_router.py diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index 7c3672f..fabd6d0 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -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). | @@ -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) diff --git a/docs/feature.md b/docs/feature.md index b00613f..b949591 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -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). | diff --git a/docs/routing.md b/docs/routing.md index 298c14d..9a5afd7 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -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 diff --git a/examples/routers_include_app.py b/examples/routers_include_app.py new file mode 100644 index 0000000..cafe617 --- /dev/null +++ b/examples/routers_include_app.py @@ -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" diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index c847d09..70ae018 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -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" diff --git a/oxyroute/app.py b/oxyroute/app.py index 9902cc7..9ef50f2 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import json from collections.abc import Callable, Mapping from types import SimpleNamespace @@ -7,6 +8,7 @@ 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 @@ -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. diff --git a/oxyroute/router.py b/oxyroute/router.py new file mode 100644 index 0000000..3e10488 --- /dev/null +++ b/oxyroute/router.py @@ -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, + ) diff --git a/tests/test_api_router.py b/tests/test_api_router.py new file mode 100644 index 0000000..bd02f66 --- /dev/null +++ b/tests/test_api_router.py @@ -0,0 +1,107 @@ +"""APIRouter + include_router (issue #46).""" + +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest +from oxyroute import APIRouter, App +from oxyroute.router import join_path + + +def test_join_path() -> None: + assert join_path("", "/a") == "/a" + assert join_path("/v1", "/a") == "/v1/a" + assert join_path("/v1", "a") == "/v1/a" + assert join_path("v1", "/a") == "/v1/a" + assert join_path("/v1", "/") == "/v1/" + + +def test_include_router_hits_routes() -> None: + r = APIRouter() + + @r.get("/a") + def a() -> str: + return "A" + + @r.get("/b/:x") + def b(x: str) -> str: + return f"B{x}" + + app = App() + app.include_router(r, prefix="/v1") + + async def _go() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://t") as c: + r1 = await c.get("/v1/a") + r2 = await c.get("/v1/b/7") + assert r1.status_code == 200 and r1.text == "A" + assert r2.status_code == 200 and r2.text == "B7" + + asyncio.run(_go()) + + +def test_include_router_openapi_paths() -> None: + r = APIRouter() + + @r.get("/items") + def items() -> str: + return "ok" + + app = App() + app.include_router(r, prefix="/api") + oa = json.loads(app.openapi_json()) + assert "/api/items" in oa.get("paths", {}) + + +def test_include_defaults_passthrough() -> None: + r = APIRouter() + + @r.get("/x") + def x() -> str: + return "x" + + app = App() + app.include_router(r, "/p") + oa = json.loads(app.openapi_json()) + assert "/p/x" in oa.get("paths", {}) + + +def test_nested_include_router() -> None: + inner = APIRouter() + + @inner.get("/c") + def c() -> str: + return "c" + + outer = APIRouter() + outer.include_router(inner, prefix="/inner") + app = App() + app.include_router(outer, prefix="/v1") + + async def _go() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://t") as c: + r0 = await c.get("/v1/inner/c") + assert r0.status_code == 200 and r0.text == "c" + + asyncio.run(_go()) + + +def test_duplicate_route_errors() -> None: + r = APIRouter() + + @r.get("/d") + def d1() -> str: + return "1" + + @r.get("/d") + def d2() -> str: + return "2" + + app = App() + with pytest.raises(ValueError, match=r"conflict|insertion"): + app.include_router(r, "/z")