Skip to content

Commit 167d885

Browse files
committed
docs: rewrite README as a compact bilingual overview
1 parent 802d065 commit 167d885

2 files changed

Lines changed: 148 additions & 99 deletions

File tree

README.md

Lines changed: 53 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,95 @@
11
# fastapi-typed-errors
22

3-
Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself.
3+
**English** · [Русский](README.ru.md)
44

5-
> **Status: draft.** All three layers are implemented — `core`, the `with_errors` decorator (with `auto`-fill), and the layer-3 CI checker.
5+
![Python](https://img.shields.io/badge/python-3.12%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green) [![Docs](https://img.shields.io/badge/docs-online-blueviolet)](https://feodor-ra.github.io/fastapi-typed-errors/)
66

7-
## Requirements
7+
**Typed HTTP errors for FastAPI** — exact `Literal` codes in OpenAPI, discriminated `oneOf` unions on the `code` field, and a single source of truth: the error class itself.
88

9-
- Python **3.12+** (PEP 695 generics)
10-
- FastAPI ≥ 0.115 (the lazy-routing gap `0.137``0.138` is excluded), Pydantic ≥ 2.9
11-
- The CI checker CLI needs the `cli` extra: `pip install 'fastapi-typed-errors[cli]'`
12-
13-
## Quick start
14-
15-
Declare errors once, right in the return annotation — `with_errors` fills `responses={}` for you:
9+
In plain FastAPI, `raise HTTPException(404, "...")` buries the error code in a string — clients can't switch on it, OpenAPI has no code type or body schema, and you hand-maintain `responses={}` on every route. This package makes an error a **class**: its HTTP status, machine-readable code and response model are declared once and derived automatically, so your error contract is typed, self-documenting, and verifiable in CI.
1610

1711
```python
18-
from enum import StrEnum
19-
from http import HTTPStatus
20-
from typing import Annotated, Literal
21-
22-
from fastapi import APIRouter, FastAPI
23-
from pydantic import BaseModel
24-
25-
from fastapi_typed_errors import BaseError, Raises, handle_base_error, with_errors
26-
27-
28-
class ErrorCode(StrEnum):
29-
NOT_FOUND = "NOT_FOUND"
30-
FORBIDDEN = "FORBIDDEN"
31-
32-
3312
class NotFoundError(BaseError[Literal[ErrorCode.NOT_FOUND]]):
3413
http_status = HTTPStatus.NOT_FOUND
35-
description = "Requested entity does not exist"
3614

3715

38-
class ForbiddenError(BaseError[Literal[ErrorCode.FORBIDDEN]]):
39-
http_status = HTTPStatus.FORBIDDEN
40-
16+
@router.get("/items/{item_id}")
17+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError]]:
18+
if item_id == 0:
19+
raise NotFoundError("No item")
20+
return Item(item_id=item_id)
21+
```
4122

42-
class Item(BaseModel):
43-
item_id: int
23+
The response body is always `{"code": "NOT_FOUND", "detail": "No item"}`, and OpenAPI gets a `404` with the **exact** `Literal["NOT_FOUND"]` code and body model — no manual `responses`.
4424

25+
## Install
4526

46-
app = FastAPI()
47-
app.add_exception_handler(BaseError, handle_base_error)
27+
```bash
28+
pip install fastapi-typed-errors # core + decorator
29+
pip install "fastapi-typed-errors[cli]" # + the CI-checker CLI
30+
```
4831

49-
router = with_errors(APIRouter())
32+
Requires Python **3.12+**, FastAPI **≥ 0.115**, Pydantic **≥ 2.9**.
5033

34+
## How to use it
5135

52-
@router.get("/items/{item_id}")
53-
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]:
54-
if item_id == 0:
55-
raise NotFoundError(f"No item {item_id}")
56-
return Item(item_id=item_id)
36+
**1. Define errors and register the one handler.**
5737

38+
```python
39+
from fastapi import FastAPI
40+
from fastapi_typed_errors import BaseError, handle_base_error
5841

59-
app.include_router(router)
42+
app = FastAPI()
43+
app.add_exception_handler(BaseError, handle_base_error)
6044
```
6145

62-
The error response body is always:
46+
**2. Declare errors — pick your level of magic:**
6347

64-
```json
65-
{"code": "NOT_FOUND", "detail": "No item 0"}
66-
```
48+
```python
49+
# a) by hand (core only) — write responses yourself
50+
@app.get("/x", responses={404: {"model": error_models(NotFoundError)}})
51+
def a() -> Item: ...
6752

68-
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.
6953

70-
`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)`.
54+
# b) declared — the marker fills responses for you
55+
router = with_errors(APIRouter())
7156

72-
### Auto-fill
7357

74-
Pass `with_errors(router, auto=True)` to drop the `Raises[...]` markers entirely: at registration each endpoint and its whole dependency tree are statically walked (the same walk the [CI checker](#ci-checker) uses), and the discovered errors fill `responses` automatically — merged with any markers you *do* write, and an explicit `responses={}` still wins per status.
58+
@router.get("/y")
59+
def b() -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
7560

76-
```python
61+
62+
# c) automatic — no markers at all; errors are found statically
7763
router = with_errors(APIRouter(), auto=True)
7864

7965

80-
@router.get("/items/{item_id}")
81-
def get_item(item_id: int, user: Annotated[User, Depends(current_user)]) -> Item:
82-
if item_id == 0:
83-
raise NotFoundError(f"No item {item_id}") # auto -> 404
84-
return Item(item_id=item_id) # + whatever current_user can raise
66+
@router.get("/z")
67+
def c(user: Annotated[User, Depends(current_user)]) -> Item:
68+
raise NotFoundError("...") # auto -> 404, plus whatever current_user raises
8569
```
8670

87-
## Core layer only
88-
89-
The decorator layer is optional sugar — `responses={}` can always be filled by hand with `error_models()`:
71+
**3. Verify the contract in CI.** `check_raises` compares what each route *declares* against what it can actually *raise* — in the endpoint and its whole dependency tree:
9072

9173
```python
92-
@app.get(
93-
"/items/{item_id}",
94-
responses={
95-
404: {"model": error_models(NotFoundError)},
96-
403: {"model": error_models(ForbiddenError)},
97-
},
98-
)
99-
def get_item(item_id: int) -> Item: ...
74+
def test_error_contracts() -> None:
75+
assert check_raises(app).ok
10076
```
10177

102-
## CI checker
78+
Or as a command: `fastapi-typed-errors check app.main:app` (exit `0`/`1`/`2`).
10379

104-
`check_raises` statically compares what each route **declares** via `Raises[...]` against what it can actually **raise** — in the endpoint, its helpers and its whole dependency tree. It closes the gap that pure annotations leave open: a raise you forgot to declare, or a declaration you no longer raise.
80+
## Why it's cool
10581

106-
```python
107-
from fastapi_typed_errors import check_raises
82+
- **Exact types in OpenAPI** — a precise `Literal` code per status; several errors on one status become a discriminated `oneOf` union, so Swagger UI shows a variant picker by code.
83+
- **Single source of truth** — status, code and model declared once; the metaclass derives the rest.
84+
- **Zero-boilerplate `responses`** — via the `Raises` marker or fully automatic `auto=True`.
85+
- **Static contract checking**`check_raises` catches a raise you forgot to declare (or a dead declaration) before it ships.
86+
- **Non-invasive**`with_errors` patches the router in place and preserves object identity, so `include_router`, websockets and app-level decorators keep working natively.
87+
- **Rigorous** — fully typed (`py.typed`), 100% branch-covered, checked with `ruff` + `ty` at max strictness.
10888

89+
## Documentation
10990

110-
def test_error_contracts() -> None:
111-
report = check_raises(app) # a FastAPI app or an APIRouter
112-
assert report.ok, report.routes
113-
```
114-
115-
`report.routes` lists each `RouteDiscrepancy` with two independent buckets: `undeclared` (raised in code, missing from `Raises` — always a failure) and `overdeclared` (declared but never found raised). Pass `allow_overdeclared=True` to ignore the second bucket for code whose raises the static walker cannot see.
91+
📖 **[Full documentation](https://feodor-ra.github.io/fastapi-typed-errors/)** — guide, customization (custom envelopes, `ABC`, bare-string codes), limitations, and an auto-generated API reference. Available in English and Russian.
11692

117-
The same check as a CLI (needs the `cli` extra), pointed at a `module:attribute` app path:
118-
119-
```console
120-
$ fastapi-typed-errors check app.main:app
121-
```
93+
## License
12294

123-
Exit codes: `0` all declarations match, `1` discrepancies (rendered as a table), `2` a usage/loading error. Flags: `--allow-overdeclared`, `--max-depth`.
124-
125-
The walker follows the `get_or_404(error=NotFoundError)` factory pattern (error classes passed as call arguments), closures, cross-module helpers and `functools.partial`; it stays within one process and never executes your code. Out of scope (documented false-negatives, so `overdeclared` stays a failure by default): errors raised through local-variable indirection, `self.method()` chains, dynamic dispatch, and bare `AsyncIterator` stream endpoints.
126-
127-
## Notes
128-
129-
- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`.
130-
- Wrap **before** registering: routes added to the router before `with_errors(router)` are not retrofitted.
131-
- An explicit `responses={<status>: ...}` on the route wins wholesale over `Raises`-derived entries for the same status. Use `int` status keys.
132-
- Shared error tuples work in both spellings: `Raises[*TOKEN_ERRORS]` and `Raises(*TOKEN_ERRORS)`.
133-
- 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.
134-
- 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`.
135-
- `-> Annotated[Response subclass, Raises[...]]` and `-> Annotated[None, Raises[...]]` are normalized to `response_model=None`, restoring stock FastAPI semantics for raw-response and empty routes.
136-
- Bare stream returns (`-> Annotated[AsyncIterator[X], Raises[...]]`, the SSE/JSONL feature) are not supported — annotate a `Response` subclass instead.
137-
- `Raises` metadata on a router that was **not** passed through `with_errors` is inert — nothing is injected and nothing fails; the [CI checker](#ci-checker) reads annotations directly, so it still catches such drift.
138-
- 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.
139-
- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse` — extra fields are added alongside `code`/`detail` (e.g. `status: Literal["error"] = "error"`). The shape must stay **flat**: nested envelopes like `{"status": ..., "data": {"code": ...}}` are not supported, because pydantic discriminated unions require the `code` discriminator at the top level of the model.
140-
- `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).
141-
- 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.
95+
[MIT](LICENSE).

README.ru.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# fastapi-typed-errors
2+
3+
[English](README.md) · **Русский**
4+
5+
![Python](https://img.shields.io/badge/python-3.12%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green) [![Docs](https://img.shields.io/badge/docs-online-blueviolet)](https://feodor-ra.github.io/fastapi-typed-errors/ru/)
6+
7+
**Типизированные HTTP-ошибки для FastAPI** — точные `Literal`-коды в OpenAPI, discriminated `oneOf`-union по полю `code` и единый источник правды: сам класс ошибки.
8+
9+
В обычном FastAPI `raise HTTPException(404, "...")` прячет код ошибки в строку — клиент не может по нему переключиться, в OpenAPI нет ни типа кода, ни схемы тела, а `responses={}` приходится вести руками на каждом роуте. Этот пакет делает ошибку **классом**: её HTTP-статус, машинный код и модель ответа объявляются один раз и выводятся автоматически, так что контракт ошибок становится типизированным, самодокументируемым и проверяемым в CI.
10+
11+
```python
12+
class NotFoundError(BaseError[Literal[ErrorCode.NOT_FOUND]]):
13+
http_status = HTTPStatus.NOT_FOUND
14+
15+
16+
@router.get("/items/{item_id}")
17+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError]]:
18+
if item_id == 0:
19+
raise NotFoundError("No item")
20+
return Item(item_id=item_id)
21+
```
22+
23+
Тело ответа всегда `{"code": "NOT_FOUND", "detail": "No item"}`, а в OpenAPI роут получает `404` с **точным** кодом `Literal["NOT_FOUND"]` и моделью тела — без ручного `responses`.
24+
25+
## Установка
26+
27+
```bash
28+
pip install fastapi-typed-errors # ядро + декоратор
29+
pip install "fastapi-typed-errors[cli]" # + CLI для CI-проверки
30+
```
31+
32+
Требуется Python **3.12+**, FastAPI **≥ 0.115**, Pydantic **≥ 2.9**.
33+
34+
## Как это использовать
35+
36+
**1. Объявите ошибки и зарегистрируйте единственный обработчик.**
37+
38+
```python
39+
from fastapi import FastAPI
40+
from fastapi_typed_errors import BaseError, handle_base_error
41+
42+
app = FastAPI()
43+
app.add_exception_handler(BaseError, handle_base_error)
44+
```
45+
46+
**2. Декларируйте ошибки — выберите уровень магии:**
47+
48+
```python
49+
# а) руками (только ядро) — responses пишете сами
50+
@app.get("/x", responses={404: {"model": error_models(NotFoundError)}})
51+
def a() -> Item: ...
52+
53+
54+
# б) декларативно — маркер заполняет responses за вас
55+
router = with_errors(APIRouter())
56+
57+
58+
@router.get("/y")
59+
def b() -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
60+
61+
62+
# в) автоматически — вообще без маркеров; ошибки находятся статически
63+
router = with_errors(APIRouter(), auto=True)
64+
65+
66+
@router.get("/z")
67+
def c(user: Annotated[User, Depends(current_user)]) -> Item:
68+
raise NotFoundError("...") # auto -> 404, плюс всё, что поднимает current_user
69+
```
70+
71+
**3. Проверьте контракт в CI.** `check_raises` сверяет, что каждый роут *декларирует*, с тем, что он реально может *поднять* — в эндпоинте и во всём дереве зависимостей:
72+
73+
```python
74+
def test_error_contracts() -> None:
75+
assert check_raises(app).ok
76+
```
77+
78+
Или как команда: `fastapi-typed-errors check app.main:app` (exit `0`/`1`/`2`).
79+
80+
## Почему это круто
81+
82+
- **Точные типы в OpenAPI** — точный `Literal`-код на каждый статус; несколько ошибок на одном статусе становятся discriminated `oneOf`-union, и Swagger UI показывает выбор варианта по коду.
83+
- **Единый источник правды** — статус, код и модель объявляются один раз; остальное выводит метакласс.
84+
- **Ноль бойлерплейта в `responses`** — через маркер `Raises` или полностью автоматический `auto=True`.
85+
- **Статическая проверка контракта**`check_raises` ловит забытую декларацию (или мёртвую) ещё до релиза.
86+
- **Не инвазивно**`with_errors` патчит роутер на месте и сохраняет идентичность объекта, поэтому `include_router`, websockets и декораторы приложения продолжают работать нативно.
87+
- **Строго** — полностью типизирован (`py.typed`), 100% покрытие по веткам, проверка `ruff` + `ty` на максимальной строгости.
88+
89+
## Документация
90+
91+
📖 **[Полная документация](https://feodor-ra.github.io/fastapi-typed-errors/ru/)** — руководство, кастомизация (свои конверты, `ABC`, голые строковые коды), ограничения и авто-генерируемый справочник API. Доступна на английском и русском.
92+
93+
## Лицензия
94+
95+
[MIT](LICENSE).

0 commit comments

Comments
 (0)