Skip to content

Commit d902049

Browse files
committed
feat(decorator): add with_errors router patch and Raises marker
1 parent 323e574 commit d902049

7 files changed

Lines changed: 383 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ Public PyPI package: typed HTTP errors for FastAPI — exact `Literal` error cod
66

77
## Status
88

9-
Draft. Only the `core` layer is implemented. Linters/type checker are configured (see Tooling); tests are not set up yet. Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
9+
Draft. The `core` and `decorator` layers are implemented. Linters/type checker are configured (see Conventions); tests are not set up yet (verification via ad-hoc smoke scripts outside the repo). Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
1010

1111
## Architecture — three independent layers
1212

1313
Each layer is usable without the next one:
1414

1515
1. **`core`** (done) — `BaseError`, the `BaseErrorMeta` metaclass, `ErrorResponse`, `error_models()`, `handle_base_error`.
16-
2. **`decorator`** (planned) — `with_errors(router)` + `Support[...]` in the return annotation (Annotated syntax). A router wrapper with a single interception point in `add_api_route`; subclassing `APIRouter` was rejected.
16+
2. **`decorator`** (done) — `with_errors(router)` + `Raises[...]` in the return annotation (Annotated syntax). An instance patch of `add_api_route` as the single interception point; subclassing `APIRouter` AND a wrapper object were both rejected (see decorator-layer decisions).
1717
3. **`analysis`** (planned) — AST walk over `raise` statements: a CI checker (declared vs actually raised) and `auto=True` (auto-populating `responses`). Study fastapi-docx before implementing.
1818

1919
## Layout
@@ -23,7 +23,9 @@ src-layout, package `src/fastapi_typed_errors/`:
2323
- `core/base.py``_model_title`, `ErrorResponse`, `BaseErrorMeta`, `BaseError` (hard dependency order, see the ordering convention).
2424
- `core/models.py``error_models()` with 8 `@overload`s (typeshed pattern) + catch-all.
2525
- `core/handlers.py``handle_base_error`.
26-
- `__init__.py` / `core/__init__.py` — public API re-exports.
26+
- `decorator/raises.py` — the `Raises` marker (validated at construction).
27+
- `decorator/wrapper.py``with_errors()` + the `add_api_route` patch and private helpers.
28+
- `__init__.py` / subpackage `__init__.py` — public API re-exports.
2729
- `py.typed` — the package is typed.
2830

2931
## Key core-layer decisions
@@ -39,14 +41,30 @@ src-layout, package `src/fastapi_typed_errors/`:
3941
- **No `install()`-style helpers** — the user wires everything explicitly, the FastAPI way (`app.add_exception_handler(BaseError, handle_base_error)`), same as `app.add_middleware(...)`. Starlette resolves handlers by walking `__mro__`, so the `BaseError` handler overrides the default `HTTPException` one without touching plain `HTTPException`.
4042
- `handle_base_error` is typed `(Request, Exception)` to match Starlette's `ExceptionHandler` contract: parameters are contravariant, so a narrower `BaseError` parameter would force a suppression onto every user's registration line (verified with ty; dropping the generic parameter does not help). Misregistration is caught at runtime instead — a non-`BaseError` argument raises a `TypeError` naming the offending type.
4143
- `BaseErrorMeta` is public so users can resolve metaclass conflicts (mixing `BaseError` with `ABC` etc.) via a combined metaclass, but it is deliberately NOT re-exported from any `__init__` — import from `fastapi_typed_errors.core.base`. Documented in README.
42-
- Rule for the future decorator layer: an explicit user `responses={}` wins over `Support` errors with the same status.
44+
- Rule for the future decorator layer: an explicit user `responses={}` wins over `Raises` errors with the same status.
45+
46+
## Key decorator-layer decisions
47+
48+
- **Instance patch, not a wrapper object** (user decision 2026-07-22, supersedes the concept doc): `with_errors[R: APIRouter](router: R, /) -> R` replaces `router.add_api_route` with a `functools.wraps` decorator on the *instance* and returns the *same* router. Reason: FastAPI 0.139's lazy `include_router` keeps the included object and `APIRouter.matches` does an **identity check** (`included_router.original_router is self`) — any duck-typed wrapper passes OpenAPI generation but silently 404s at runtime. Identity preservation makes `include_router`, websockets, app-level decorators and imperative registration work natively on every FastAPI version.
49+
- Single funnel: all 8 verb decorators + `api_route` + all `app.*` methods delegate into `router.add_api_route` (verified in 0.139 sources), so one patch covers everything. For an application: `with_errors(app.router)`.
50+
- The patch is idempotent (flag attribute `_fastapi_typed_errors_wrapped` on the replacement).
51+
- FastAPI's decorators pass `response_model` explicitly as a `DefaultPlaceholder` sentinel — "user set response_model" means the kwarg is present AND not a `DefaultPlaceholder`. Never inject `response_model=None` otherwise: it would disable return-annotation inference.
52+
- `Raises` is an inert `Annotated` metadata instance (`__class_getitem__` returns an instance; no `__get_pydantic_core_schema__`); pydantic ignores it, the 200 schema stays clean. Validation (BaseError subclass, declared code + status, non-empty) happens eagerly at `Raises[...]` evaluation, i.e. import time.
53+
- Normalization: `-> Annotated[Response-subclass | None, Raises[...]]``response_model=None` (FastAPI's `lenient_issubclass` cannot see through `Annotated`; without this the route dies with `FastAPIError` / grows a spurious `null` schema). Bare stream returns (`AsyncIterator` + Raises) are unsupported — documented limitation.
54+
- Merge semantics: derived entries lose to explicit per-route `responses` wholesale per status; router-level `responses` are merged by FastAPI itself and lose to per-route ones.
55+
- Endpoint unwrapping for annotation reading only: `functools.partial` chain → `inspect.unwrap` → callable-instance `type(obj).__call__`; the original endpoint object is always what gets registered.
56+
- `_find_raises` is recursive: unwraps PEP 695 `TypeAliasType`, descends into `Annotated` bases and union arms — a declared marker must never be dropped silently (adversarial review caught all three as silent-drop bugs). `_annotated_base` applies the same unwrapping for the Response/None normalization.
57+
- Unresolvable type hints (NameError/TypeError from `get_type_hints`): if the raw return annotation does not mention `Raises` → silent passthrough (stock FastAPI tolerates the `TYPE_CHECKING` pattern and never resolves return hints under an explicit `response_model`); if it does → fail fast with a clear `TypeError`. Never resolve stricter than stock for marker-free endpoints.
58+
- Follow-up idea (not implemented): PEP 692 `Unpack[TypedDict]` typing for registration kwargs.
4359

4460
## Conventions
4561

4662
- This file and all code artifacts (docstrings, comments) are in English; communication with the user is in Russian.
4763
- Commits: Conventional Commits with English descriptions (`feat(core): ...`, `chore: ...`, `docs: ...`).
4864
- Member ordering, both at module level and inside classes: public first, then protected (`_name`), then private. Deviate only when definition-time dependencies force it — e.g. in `core/base.py` `_model_title` must precede `ErrorResponse` (referenced in its class body), and `ErrorResponse` -> `BaseErrorMeta` -> `BaseError` is a hard dependency chain (annotations evaluate eagerly on Python < 3.14).
4965
- Docstrings: **Google style** (Args/Returns/Raises/Attributes/Example).
66+
- Prefer PEP 695 generics (`[**P, R]`, `[R]`) over `Callable[..., Any]` in decorator/pass-through helpers; when P.kwargs must be mutated, localize the lie in a single `cast(dict[str, Any], kwargs)` alias instead of suppressions. Module-level constants are annotated `Final`.
67+
- Relative imports inside the package are welcome (`from ..core.base import ...`) — TID252 (`relative-imports`) is in the ignore list.
5068
- `Returns:` must state the return type: `type: Description` (e.g. `str | None: The declared code...`).
5169
- Type checker: **ty**, max strictness (`[tool.ty.rules] all = "error"`). Suppressions use ty-style comments (`# ty: ignore[rule]`); ty does not recognize mypy rule codes inside `# type: ignore[...]`.
5270
- Linter/formatter: **ruff** with `select = ["ALL"]` + `preview = true`, ignoring only the `TC` and `CPY` modules and `missing-trailing-comma` (COM812, formatter conflict); pydocstyle convention = google. Formatter: double quotes, `line-length = 120`. Import order: ruff isort defaults (stdlib `import` then `from` imports, third-party, local) — exactly the preferred style, no extra config needed.

README.md

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@
22

33
Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself.
44

5-
> **Status: draft.** Layer 1 (`core`) only; the `with_errors` decorator and AST analysis layers are coming.
5+
> **Status: draft.** Layers 1 (`core`) and 2 (`decorator`) are implemented; the AST analysis layer (CI checker + `auto`) is coming.
66
77
## Requirements
88

99
- Python **3.12+** (PEP 695 generics)
10-
- FastAPI ≥ 0.115, Pydantic ≥ 2.7
10+
- FastAPI ≥ 0.115, Pydantic ≥ 2.9
1111

1212
## Quick start
1313

14+
Declare errors once, right in the return annotation — `with_errors` fills `responses={}` for you:
15+
1416
```python
1517
from enum import StrEnum
1618
from http import HTTPStatus
17-
from typing import Literal
19+
from typing import Annotated, Literal
20+
21+
from fastapi import APIRouter, FastAPI
22+
from pydantic import BaseModel
1823

19-
from fastapi import FastAPI
20-
from fastapi_typed_errors import BaseError, error_models, handle_base_error
24+
from fastapi_typed_errors import BaseError, Raises, handle_base_error, with_errors
2125

2226

2327
class ErrorCode(StrEnum):
@@ -34,34 +38,63 @@ class ForbiddenError(BaseError[Literal[ErrorCode.FORBIDDEN]]):
3438
http_status = HTTPStatus.FORBIDDEN
3539

3640

41+
class Item(BaseModel):
42+
item_id: int
43+
44+
3745
app = FastAPI()
3846
app.add_exception_handler(BaseError, handle_base_error)
3947

48+
router = with_errors(APIRouter())
4049

41-
@app.get(
42-
"/items/{item_id}",
43-
responses={
44-
404: {"model": error_models(NotFoundError)},
45-
403: {"model": error_models(ForbiddenError)},
46-
},
47-
)
48-
def get_item(item_id: int) -> dict[str, int]:
50+
51+
@router.get("/items/{item_id}")
52+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]:
4953
if item_id == 0:
5054
raise NotFoundError(f"No item {item_id}")
51-
return {"item_id": item_id}
55+
return Item(item_id=item_id)
56+
57+
58+
app.include_router(router)
5259
```
5360

54-
The response body is always:
61+
The error response body is always:
5562

5663
```json
5764
{"code": "NOT_FOUND", "detail": "No item 0"}
5865
```
5966

60-
and OpenAPI shows the **exact** `Literal` code per status — several errors on one status become a discriminated `oneOf` union via `error_models(A, B, ...)`.
67+
OpenAPI gets a `404` and a `403` entry with the **exact** `Literal` code each; several errors sharing one status become a discriminated `oneOf` union automatically. The success (`200`) schema stays clean — the `Raises` marker is invisible to pydantic.
68+
69+
`with_errors(router)` returns the **same** `APIRouter` instance with its `add_api_route` patched on the instance, so object identity is preserved: `include_router`, websockets, imperative `add_api_route(...)` calls and app-level decorators all work natively. For an application, wrap its router: `with_errors(app.router)`.
70+
71+
## Core layer only
72+
73+
The decorator layer is optional sugar — `responses={}` can always be filled by hand with `error_models()`:
74+
75+
```python
76+
@app.get(
77+
"/items/{item_id}",
78+
responses={
79+
404: {"model": error_models(NotFoundError)},
80+
403: {"model": error_models(ForbiddenError)},
81+
},
82+
)
83+
def get_item(item_id: int) -> Item: ...
84+
```
6185

6286
## Notes
6387

6488
- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`.
89+
- Wrap **before** registering: routes added to the router before `with_errors(router)` are not retrofitted.
90+
- An explicit `responses={<status>: ...}` on the route wins wholesale over `Raises`-derived entries for the same status. Use `int` status keys.
91+
- Shared error tuples work in both spellings: `Raises[*TOKEN_ERRORS]` and `Raises(*TOKEN_ERRORS)`.
92+
- Markers are found through PEP 695 `type` aliases, nested `Annotated` bases and union arms (`Annotated[Item, Raises[...]] | None`) — a declared `Raises` is never dropped silently.
93+
- Unresolvable return annotations without `Raises` (the `if TYPE_CHECKING:` import pattern) pass through untouched, exactly like stock FastAPI; with `Raises` mentioned they fail fast with a clear `TypeError`.
94+
- `-> Annotated[Response subclass, Raises[...]]` and `-> Annotated[None, Raises[...]]` are normalized to `response_model=None`, restoring stock FastAPI semantics for raw-response and empty routes.
95+
- Bare stream returns (`-> Annotated[AsyncIterator[X], Raises[...]]`, the SSE/JSONL feature) are not supported — annotate a `Response` subclass instead.
96+
- `Raises` metadata on a router that was **not** passed through `with_errors` is inert — nothing is injected and nothing fails (the upcoming analysis layer will catch such drift).
97+
- Status descriptions come from each error's `description`; several errors on one status get their descriptions joined with `;`, and the HTTP status phrase is the fallback.
6598
- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse`.
6699
- `BaseError` subclasses `fastapi.HTTPException`; mixing it with `ABC` and other custom-metaclass bases raises a metaclass conflict. The metaclass is public for exactly this case — build a combined one: `class Meta(BaseErrorMeta, ABCMeta): ...` (`from fastapi_typed_errors.core.base import BaseErrorMeta`; deliberately not re-exported from the package root).
67100
- The handler is registered explicitly — the same way you call `app.add_middleware(...)`; the package does not touch your app behind your back. `handle_base_error` is typed `(Request, Exception)` to match Starlette's handler contract, so the registration line stays clean under every type checker; registering it for a non-`BaseError` exception type fails fast with a `TypeError` at runtime.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ignore = [
3232
"TC", # flake8-type-checking: runtime type information is the point of this package
3333
"CPY", # flake8-copyright: no per-file copyright notices
3434
"missing-trailing-comma", # COM812: conflicts with the formatter
35+
"relative-imports", # TID252: relative imports inside the package are welcome
3536
]
3637

3738
[tool.ruff.lint.pydocstyle]

src/fastapi_typed_errors/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
error_models,
1111
handle_base_error,
1212
)
13+
from .decorator import Raises, with_errors
1314

1415
__all__ = (
1516
"BaseError",
1617
"ErrorResponse",
18+
"Raises",
1719
"error_models",
1820
"handle_base_error",
21+
"with_errors",
1922
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Layer 2 — declare raisable errors in return annotations via ``with_errors`` + ``Raises``."""
2+
3+
from .raises import Raises
4+
from .wrapper import with_errors
5+
6+
__all__ = (
7+
"Raises",
8+
"with_errors",
9+
)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""The ``Raises`` marker: declares raisable errors inside a return annotation."""
2+
3+
from typing import Any, Self, override
4+
5+
from ..core.base import BaseError
6+
7+
8+
class Raises:
9+
"""Marker listing the errors a route can raise, for ``Annotated`` return metadata.
10+
11+
The wrapper created by ``with_errors()`` reads this marker at registration
12+
time and fills the route's ``responses={}`` accordingly. The marker itself
13+
is inert: pydantic and FastAPI ignore it, so the success schema stays clean.
14+
15+
Both spellings are equivalent; the constructor form is handy for shared
16+
tuples of errors::
17+
18+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
19+
def get_user(user_id: int) -> Annotated[User, Raises(*TOKEN_ERRORS)]: ...
20+
21+
Attributes:
22+
errors: The declared error classes, in declaration order.
23+
"""
24+
25+
__slots__ = ("errors",)
26+
27+
errors: tuple[type[BaseError[Any]], ...]
28+
29+
def __init__(self, *errors: type[BaseError[Any]]) -> None:
30+
"""Validate and store the declared error classes.
31+
32+
Args:
33+
*errors: ``BaseError`` subclasses with a declared code and status.
34+
35+
Raises:
36+
TypeError: If no classes are given, a member is not a ``BaseError``
37+
subclass, or a member lacks a declared ``error_code`` /
38+
``http_status``.
39+
"""
40+
if not errors:
41+
msg = "Raises requires at least one error class"
42+
raise TypeError(msg)
43+
for error in errors:
44+
if not (isinstance(error, type) and issubclass(error, BaseError)):
45+
msg = f"Raises accepts only BaseError subclasses, got {error!r}"
46+
raise TypeError(msg)
47+
try:
48+
# Probe: the metaclass property raises on codeless classes, http_status may be undeclared.
49+
_ = (error.error_code, error.http_status)
50+
except AttributeError as exc:
51+
msg = f"{error.__name__} cannot be used in Raises: {exc}"
52+
raise TypeError(msg) from exc
53+
self.errors = errors
54+
55+
def __class_getitem__(cls, errors: type[BaseError[Any]] | tuple[type[BaseError[Any]], ...]) -> Self:
56+
"""Build a marker instance from subscription syntax.
57+
58+
``Raises[ErrA, ErrB]`` and ``Raises[*SHARED_ERRORS]`` are sugar for
59+
``Raises(ErrA, ErrB)``.
60+
61+
Args:
62+
errors: A single error class or a tuple of them.
63+
64+
Returns:
65+
Self: The validated marker instance.
66+
"""
67+
items = errors if isinstance(errors, tuple) else (errors,)
68+
return cls(*items)
69+
70+
@override
71+
def __repr__(self) -> str:
72+
"""Render the marker as its subscription form.
73+
74+
Returns:
75+
str: E.g. ``Raises[NotFoundError, ForbiddenError]``.
76+
"""
77+
names = ", ".join(error.__name__ for error in self.errors)
78+
return f"Raises[{names}]"

0 commit comments

Comments
 (0)