Skip to content

Commit 323e574

Browse files
committed
docs: add README and CLAUDE.md
1 parent 843c43c commit 323e574

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# fastapi-typed-errors
2+
3+
Public PyPI package: typed HTTP errors for FastAPI — exact `Literal` error codes in OpenAPI, discriminated `oneOf` unions on the `code` field, single source of truth (the error class itself).
4+
5+
**Keep this file up to date**: when adding features or making changes, extend and correct the relevant sections.
6+
7+
## Status
8+
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`).
10+
11+
## Architecture — three independent layers
12+
13+
Each layer is usable without the next one:
14+
15+
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.
17+
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.
18+
19+
## Layout
20+
21+
src-layout, package `src/fastapi_typed_errors/`:
22+
23+
- `core/base.py``_model_title`, `ErrorResponse`, `BaseErrorMeta`, `BaseError` (hard dependency order, see the ordering convention).
24+
- `core/models.py``error_models()` with 8 `@overload`s (typeshed pattern) + catch-all.
25+
- `core/handlers.py``handle_base_error`.
26+
- `__init__.py` / `core/__init__.py` — public API re-exports.
27+
- `py.typed` — the package is typed.
28+
29+
## Key core-layer decisions
30+
31+
- Python ≥ 3.12 (PEP 695 generics); dependencies: fastapi ≥ 0.115, pydantic ≥ 2.9 (`model_title_generator`).
32+
- The user brings their own enum: a code is any `StrEnum` member **or** a bare `Literal["CODE"]` (the `T: str` bound covers both).
33+
- The metaclass extracts the code from `__orig_bases__` (`get_origin`/`get_args`), filtering bases via `isinstance(get_origin(orig_base), mcs)`; it writes `error_code` and `model` into the namespace **before** `type.__new__` — otherwise the metaclass properties would intercept the assignment.
34+
- Eager validation: parametrizing with anything but a `TypeVar` (intermediate generic base) or a single-string `Literal` raises `TypeError` at class definition time — a mistake like `BaseError[str]` must not surface as an opaque 500 at request time.
35+
- `response_base: ClassVar` — a substitutable response model base; users subclass `ErrorResponse` generically (`class MyResp[T: str](ErrorResponse[T])`) and point their own error base class at it.
36+
- `description: ClassVar[str | None]` — the default `detail` and the OpenAPI status description (consumed by the decorator layer).
37+
- OpenAPI titles: `model_title_generator` (`_model_title`) renders `ErrorResponse[NOT_FOUND]` instead of pydantic's default that embeds the enum member `repr()` with angle brackets.
38+
- `error_models()`: deduplicates repeats; one code shared by two distinct models → a clear `TypeError` (a discriminated union requires unique discriminator values).
39+
- **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`.
40+
- `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.
41+
- `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.
43+
44+
## Conventions
45+
46+
- This file and all code artifacts (docstrings, comments) are in English; communication with the user is in Russian.
47+
- Commits: Conventional Commits with English descriptions (`feat(core): ...`, `chore: ...`, `docs: ...`).
48+
- 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).
49+
- Docstrings: **Google style** (Args/Returns/Raises/Attributes/Example).
50+
- `Returns:` must state the return type: `type: Description` (e.g. `str | None: The declared code...`).
51+
- 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[...]`.
52+
- 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.
53+
- Ruff suppressions use the new `# ruff:ignore[rule-name]` syntax — the preview rule `noqa-comments` forbids legacy `# noqa` comments. Single-rule entries in `lint.ignore` must use rule *names*, not codes (preview rule `rule-codes-in-selectors`).
54+
- Both tools are pinned exactly (`==`) in the `dev` dependency group; bump deliberately (`uv add --dev --bounds exact ty ruff`).
55+
- Lint everything: `just lint` (ruff format --check, ruff check, ty check). Run `uv run ruff format` to apply formatting.
56+
- Environment and build: uv (`uv sync`, `uv run python ...`), build backend `uv_build`.
57+
- Run smoke checks against a live FastAPI app: `uv run --with httpx python <script>` (httpx is needed by `TestClient` and is not a package dependency).

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# fastapi-typed-errors
2+
3+
Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself.
4+
5+
> **Status: draft.** Layer 1 (`core`) only; the `with_errors` decorator and AST analysis layers are coming.
6+
7+
## Requirements
8+
9+
- Python **3.12+** (PEP 695 generics)
10+
- FastAPI ≥ 0.115, Pydantic ≥ 2.7
11+
12+
## Quick start
13+
14+
```python
15+
from enum import StrEnum
16+
from http import HTTPStatus
17+
from typing import Literal
18+
19+
from fastapi import FastAPI
20+
from fastapi_typed_errors import BaseError, error_models, handle_base_error
21+
22+
23+
class ErrorCode(StrEnum):
24+
NOT_FOUND = "NOT_FOUND"
25+
FORBIDDEN = "FORBIDDEN"
26+
27+
28+
class NotFoundError(BaseError[Literal[ErrorCode.NOT_FOUND]]):
29+
http_status = HTTPStatus.NOT_FOUND
30+
description = "Requested entity does not exist"
31+
32+
33+
class ForbiddenError(BaseError[Literal[ErrorCode.FORBIDDEN]]):
34+
http_status = HTTPStatus.FORBIDDEN
35+
36+
37+
app = FastAPI()
38+
app.add_exception_handler(BaseError, handle_base_error)
39+
40+
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]:
49+
if item_id == 0:
50+
raise NotFoundError(f"No item {item_id}")
51+
return {"item_id": item_id}
52+
```
53+
54+
The response body is always:
55+
56+
```json
57+
{"code": "NOT_FOUND", "detail": "No item 0"}
58+
```
59+
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, ...)`.
61+
62+
## Notes
63+
64+
- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`.
65+
- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse`.
66+
- `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).
67+
- 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.

0 commit comments

Comments
 (0)