fastapi-canon 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_canon-0.1.0/.github/workflows/ci.yml +107 -0
- fastapi_canon-0.1.0/.gitignore +12 -0
- fastapi_canon-0.1.0/.pre-commit-config.yml +17 -0
- fastapi_canon-0.1.0/.python-version +1 -0
- fastapi_canon-0.1.0/PKG-INFO +234 -0
- fastapi_canon-0.1.0/README.md +212 -0
- fastapi_canon-0.1.0/examples/__init__.py +0 -0
- fastapi_canon-0.1.0/examples/showcase/README.md +64 -0
- fastapi_canon-0.1.0/examples/showcase/__init__.py +0 -0
- fastapi_canon-0.1.0/examples/showcase/app.py +19 -0
- fastapi_canon-0.1.0/examples/showcase/features/__init__.py +0 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/__init__.py +13 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/errors.py +38 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/exceptions.py +13 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/models.py +10 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/providers.py +10 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/router.py +31 -0
- fastapi_canon-0.1.0/examples/showcase/features/catalog/service.py +25 -0
- fastapi_canon-0.1.0/examples/showcase/features/status/__init__.py +7 -0
- fastapi_canon-0.1.0/examples/showcase/features/status/router.py +8 -0
- fastapi_canon-0.1.0/fastapi_canon/__init__.py +22 -0
- fastapi_canon-0.1.0/fastapi_canon/error/__init__.py +11 -0
- fastapi_canon-0.1.0/fastapi_canon/error/contracts.py +71 -0
- fastapi_canon-0.1.0/fastapi_canon/error/error.py +273 -0
- fastapi_canon-0.1.0/fastapi_canon/error/handlers.py +277 -0
- fastapi_canon-0.1.0/fastapi_canon/error/openapi.py +437 -0
- fastapi_canon-0.1.0/fastapi_canon/error/problem.py +96 -0
- fastapi_canon-0.1.0/fastapi_canon/error/registry.py +273 -0
- fastapi_canon-0.1.0/fastapi_canon/error/rendering.py +91 -0
- fastapi_canon-0.1.0/fastapi_canon/error/types.py +73 -0
- fastapi_canon-0.1.0/fastapi_canon/error/validation.py +18 -0
- fastapi_canon-0.1.0/fastapi_canon/feature.py +395 -0
- fastapi_canon-0.1.0/fastapi_canon/py.typed +0 -0
- fastapi_canon-0.1.0/pyproject.toml +69 -0
- fastapi_canon-0.1.0/tests/error/__init__.py +0 -0
- fastapi_canon-0.1.0/tests/error/contract/__init__.py +0 -0
- fastapi_canon-0.1.0/tests/error/contract/test_openapi.py +360 -0
- fastapi_canon-0.1.0/tests/error/integration/__init__.py +0 -0
- fastapi_canon-0.1.0/tests/error/integration/test_handlers.py +311 -0
- fastapi_canon-0.1.0/tests/error/integration/test_http_exceptions.py +88 -0
- fastapi_canon-0.1.0/tests/error/integration/test_request_validation.py +90 -0
- fastapi_canon-0.1.0/tests/error/unit/__init__.py +0 -0
- fastapi_canon-0.1.0/tests/error/unit/test_error.py +215 -0
- fastapi_canon-0.1.0/tests/error/unit/test_modern_python.py +36 -0
- fastapi_canon-0.1.0/tests/error/unit/test_package.py +14 -0
- fastapi_canon-0.1.0/tests/error/unit/test_problem.py +93 -0
- fastapi_canon-0.1.0/tests/error/unit/test_registry.py +233 -0
- fastapi_canon-0.1.0/tests/error/unit/test_rendering.py +146 -0
- fastapi_canon-0.1.0/tests/test_features.py +332 -0
- fastapi_canon-0.1.0/uv.lock +1013 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
quality:
|
|
12
|
+
name: Quality
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
17
|
+
with:
|
|
18
|
+
enable-cache: true
|
|
19
|
+
python-version: "3.14"
|
|
20
|
+
- run: uv sync --frozen --all-groups
|
|
21
|
+
- run: >-
|
|
22
|
+
uv run pre-commit run
|
|
23
|
+
--config .pre-commit-config.yml
|
|
24
|
+
--all-files
|
|
25
|
+
--show-diff-on-failure
|
|
26
|
+
- run: uv run mypy
|
|
27
|
+
|
|
28
|
+
test:
|
|
29
|
+
name: Test (${{ matrix.os }}, Python ${{ matrix.python }}, ${{ matrix.resolution }})
|
|
30
|
+
runs-on: ${{ matrix.os }}
|
|
31
|
+
strategy:
|
|
32
|
+
fail-fast: false
|
|
33
|
+
matrix:
|
|
34
|
+
include:
|
|
35
|
+
- os: ubuntu-latest
|
|
36
|
+
python: "3.12"
|
|
37
|
+
resolution: locked
|
|
38
|
+
- os: ubuntu-latest
|
|
39
|
+
python: "3.13"
|
|
40
|
+
resolution: locked
|
|
41
|
+
- os: ubuntu-latest
|
|
42
|
+
python: "3.14"
|
|
43
|
+
resolution: locked
|
|
44
|
+
- os: ubuntu-latest
|
|
45
|
+
python: "3.12"
|
|
46
|
+
resolution: lowest-direct
|
|
47
|
+
- os: ubuntu-latest
|
|
48
|
+
python: "3.14"
|
|
49
|
+
resolution: latest
|
|
50
|
+
- os: windows-latest
|
|
51
|
+
python: "3.14"
|
|
52
|
+
resolution: locked
|
|
53
|
+
- os: macos-latest
|
|
54
|
+
python: "3.14"
|
|
55
|
+
resolution: locked
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@v7
|
|
58
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
59
|
+
with:
|
|
60
|
+
enable-cache: true
|
|
61
|
+
python-version: ${{ matrix.python }}
|
|
62
|
+
- if: matrix.resolution == 'locked'
|
|
63
|
+
run: uv sync --frozen --all-groups
|
|
64
|
+
- if: matrix.resolution == 'lowest-direct'
|
|
65
|
+
run: >-
|
|
66
|
+
uv run --isolated --no-project --python 3.12
|
|
67
|
+
--with .
|
|
68
|
+
--with "dishka==1.10.0"
|
|
69
|
+
--with "fastapi==0.115.0"
|
|
70
|
+
--with "pydantic==2.9.0"
|
|
71
|
+
--with "httpx==0.27.0"
|
|
72
|
+
--with "httpx2>=2.12.0"
|
|
73
|
+
--with "pytest>=8.3"
|
|
74
|
+
--with "jsonschema>=4.23"
|
|
75
|
+
--with "openapi-spec-validator>=0.7.1"
|
|
76
|
+
pytest
|
|
77
|
+
- if: matrix.resolution == 'latest'
|
|
78
|
+
run: >-
|
|
79
|
+
uv run --isolated --no-project --python 3.14
|
|
80
|
+
--with .
|
|
81
|
+
--with httpx
|
|
82
|
+
--with httpx2
|
|
83
|
+
--with pytest
|
|
84
|
+
--with jsonschema
|
|
85
|
+
--with openapi-spec-validator
|
|
86
|
+
pytest
|
|
87
|
+
- if: matrix.resolution == 'locked'
|
|
88
|
+
run: uv run pytest
|
|
89
|
+
|
|
90
|
+
package:
|
|
91
|
+
name: Build and clean-wheel import
|
|
92
|
+
runs-on: ubuntu-latest
|
|
93
|
+
steps:
|
|
94
|
+
- uses: actions/checkout@v7
|
|
95
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
96
|
+
with:
|
|
97
|
+
enable-cache: true
|
|
98
|
+
python-version: "3.14"
|
|
99
|
+
- run: uv build
|
|
100
|
+
- run: uv venv --python 3.14 .wheel-venv
|
|
101
|
+
- run: uv pip install --python .wheel-venv/bin/python dist/*.whl
|
|
102
|
+
- run: >-
|
|
103
|
+
.wheel-venv/bin/python -I -c
|
|
104
|
+
"from fastapi_canon import Composition, Error, ErrorConfigurationError,
|
|
105
|
+
ErrorOptions, ErrorRegistry, Feature, Problem;
|
|
106
|
+
assert all((Composition, Error, ErrorConfigurationError,
|
|
107
|
+
ErrorOptions, ErrorRegistry, Feature, Problem))"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.16.6
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff-check
|
|
6
|
+
args: [--fix, --line-length=88]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
args: [--line-length=88]
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
11
|
+
rev: v6.0.0
|
|
12
|
+
hooks:
|
|
13
|
+
- id: check-merge-conflict
|
|
14
|
+
- id: check-toml
|
|
15
|
+
- id: check-yaml
|
|
16
|
+
- id: end-of-file-fixer
|
|
17
|
+
- id: trailing-whitespace
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastapi-canon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Opinionated FastAPI feature composition with Dishka and Problem Details
|
|
5
|
+
Project-URL: Homepage, https://github.com/mathisarends/fastapi_canon
|
|
6
|
+
Project-URL: Repository, https://github.com/mathisarends/fastapi_canon
|
|
7
|
+
Project-URL: Issues, https://github.com/mathisarends/fastapi_canon/issues
|
|
8
|
+
Author: mathisarends
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: <3.15,>=3.12
|
|
18
|
+
Requires-Dist: dishka<2,>=1.10
|
|
19
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# fastapi-canon
|
|
24
|
+
|
|
25
|
+
`fastapi-canon` is an opinionated composition library for feature-oriented
|
|
26
|
+
FastAPI applications. A feature groups its routers, Dishka providers, error
|
|
27
|
+
contracts, exception handlers, and lifespan into one immutable value. The
|
|
28
|
+
application installs an explicitly ordered set of those values at its
|
|
29
|
+
composition root.
|
|
30
|
+
|
|
31
|
+
Dishka is a deliberate part of this canon, not an optional integration.
|
|
32
|
+
`fastapi-canon` defines one dependency-injection approach: features contribute
|
|
33
|
+
Dishka providers, and the composition builds and owns one shared Dishka
|
|
34
|
+
container. Applications that choose another dependency-injection framework are
|
|
35
|
+
outside the library's intended architecture.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from fastapi import APIRouter, FastAPI
|
|
39
|
+
from fastapi_canon import Composition, Feature
|
|
40
|
+
|
|
41
|
+
projects = APIRouter(prefix="/projects", tags=["projects"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@projects.get("")
|
|
45
|
+
async def list_projects() -> list[str]:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
project_feature = Feature(routers=[projects])
|
|
50
|
+
|
|
51
|
+
app = Composition(project_feature).apply(FastAPI())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Contributions
|
|
55
|
+
|
|
56
|
+
Every contribution is optional:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
feature = Feature(
|
|
60
|
+
routers=[router],
|
|
61
|
+
providers=[provider],
|
|
62
|
+
errors=feature_errors,
|
|
63
|
+
exception_handlers=[handler_spec],
|
|
64
|
+
lifespan=feature_lifespan,
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Mutable sequences passed to `Feature` are copied to tuples. Installing features
|
|
69
|
+
preserves declaration order for routes and startup. Shutdown runs in reverse
|
|
70
|
+
order, including cleanup of features that started before a later feature failed.
|
|
71
|
+
|
|
72
|
+
### Dishka
|
|
73
|
+
|
|
74
|
+
Dishka is a required runtime dependency and the canonical dependency-injection
|
|
75
|
+
mechanism. Provider instances from every feature are validated together and
|
|
76
|
+
used to build one `AsyncContainer`. Applying the composition configures
|
|
77
|
+
Dishka's FastAPI middleware and closes the container during application
|
|
78
|
+
shutdown. Dishka exposes the container as `app.state.dishka_container`.
|
|
79
|
+
|
|
80
|
+
### Errors
|
|
81
|
+
|
|
82
|
+
Error contracts are implemented directly by `fastapi-canon`; no separate error
|
|
83
|
+
library is required. Each feature may expose one `ErrorRegistry`. Registries are
|
|
84
|
+
merged and installed once, so runtime RFC 9457 Problem Details and OpenAPI use
|
|
85
|
+
the same definitions:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from fastapi_canon import Composition, Error, ErrorOptions, ErrorRegistry, Feature
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ProjectNotFound(Exception):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
project_not_found = Error(
|
|
96
|
+
ProjectNotFound,
|
|
97
|
+
status=404,
|
|
98
|
+
code="project_not_found",
|
|
99
|
+
title="Project not found",
|
|
100
|
+
detail=lambda error: str(error),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
project_errors = ErrorRegistry(
|
|
104
|
+
name="projects",
|
|
105
|
+
errors=[project_not_found],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
project_feature = Feature(
|
|
109
|
+
routers=[projects],
|
|
110
|
+
errors=project_errors,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
app = Composition(
|
|
114
|
+
project_feature,
|
|
115
|
+
errors=ErrorOptions(
|
|
116
|
+
type_base="https://api.example.com/problems",
|
|
117
|
+
),
|
|
118
|
+
).apply(FastAPI())
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Declare endpoint responses from the same registry used at runtime:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@projects.get(
|
|
125
|
+
"/{project_id}",
|
|
126
|
+
responses=project_errors.responses(project_not_found),
|
|
127
|
+
)
|
|
128
|
+
async def get_project(project_id: str) -> dict[str, str]:
|
|
129
|
+
raise ProjectNotFound(project_id)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### OpenAPI representation
|
|
133
|
+
|
|
134
|
+
Every declared error becomes a reusable schema in `components.schemas`. The
|
|
135
|
+
corresponding operation references it as an `application/problem+json`
|
|
136
|
+
response:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
paths:
|
|
140
|
+
/projects/{project_id}:
|
|
141
|
+
get:
|
|
142
|
+
responses:
|
|
143
|
+
"404":
|
|
144
|
+
description: Project not found
|
|
145
|
+
content:
|
|
146
|
+
application/problem+json:
|
|
147
|
+
schema:
|
|
148
|
+
$ref: "#/components/schemas/ProjectNotFoundProblem"
|
|
149
|
+
|
|
150
|
+
components:
|
|
151
|
+
schemas:
|
|
152
|
+
ProjectNotFoundProblem:
|
|
153
|
+
type: object
|
|
154
|
+
required: [type, title, status, code]
|
|
155
|
+
properties:
|
|
156
|
+
type:
|
|
157
|
+
type: string
|
|
158
|
+
const: https://api.example.com/problems/project_not_found
|
|
159
|
+
title:
|
|
160
|
+
type: string
|
|
161
|
+
const: Project not found
|
|
162
|
+
status:
|
|
163
|
+
type: integer
|
|
164
|
+
const: 404
|
|
165
|
+
code:
|
|
166
|
+
type: string
|
|
167
|
+
const: project_not_found
|
|
168
|
+
detail:
|
|
169
|
+
type: [string, "null"]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Typed extension fields and documented response headers are added to that same
|
|
173
|
+
schema and response. If multiple errors share one status code, the response
|
|
174
|
+
uses `oneOf` with `code` as its discriminator. When validation normalization is
|
|
175
|
+
enabled, FastAPI's default `422` response is replaced by
|
|
176
|
+
`RequestValidationProblem` using the same media type.
|
|
177
|
+
|
|
178
|
+
When all local registries already share a `type_base`, it is inferred. The
|
|
179
|
+
`ErrorOptions` settings `include_validation_error`,
|
|
180
|
+
`include_http_exceptions`, and `include_unhandled_error` are passed to
|
|
181
|
+
the integrated error engine and default to `True`.
|
|
182
|
+
|
|
183
|
+
Use `ExceptionHandlerSpec` for a deliberately custom Starlette/FastAPI handler:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from fastapi_canon import ExceptionHandlerSpec
|
|
187
|
+
|
|
188
|
+
feature = Feature(
|
|
189
|
+
exception_handlers=[ExceptionHandlerSpec(DomainError, domain_error_handler)],
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Installation guarantees
|
|
194
|
+
|
|
195
|
+
- Feature order is explicit and deterministic.
|
|
196
|
+
- Reinstalling the exact same feature objects with the same options is a no-op.
|
|
197
|
+
- A different second installation is rejected.
|
|
198
|
+
- Duplicate routers, providers, handlers, and error collisions fail during
|
|
199
|
+
configuration.
|
|
200
|
+
- Known configuration errors are validated against a temporary application
|
|
201
|
+
before the real application is changed.
|
|
202
|
+
- Provider-backed features must be installed before the application starts.
|
|
203
|
+
- Disabling a feature means omitting it from `Composition`, which removes all of
|
|
204
|
+
its contributions together.
|
|
205
|
+
|
|
206
|
+
Configuration failures raise `FeatureConfigurationError`.
|
|
207
|
+
|
|
208
|
+
## Requirements
|
|
209
|
+
|
|
210
|
+
- CPython 3.12, 3.13, or 3.14
|
|
211
|
+
- FastAPI 0.115 or newer, below 1.0
|
|
212
|
+
- Dishka 1.10 or newer, below 2.0
|
|
213
|
+
- Pydantic 2.9 or newer, below 3.0
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
Install the development dependencies and run the quality gates:
|
|
218
|
+
|
|
219
|
+
```console
|
|
220
|
+
uv sync --all-groups
|
|
221
|
+
uv run pre-commit install --config .pre-commit-config.yml
|
|
222
|
+
uv run pre-commit run --config .pre-commit-config.yml --all-files
|
|
223
|
+
uv run ruff format --check .
|
|
224
|
+
uv run ruff check .
|
|
225
|
+
uv run mypy
|
|
226
|
+
uv run pytest
|
|
227
|
+
uv build
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Showcase
|
|
231
|
+
|
|
232
|
+
See [`examples/showcase`](examples/showcase) for a runnable two-feature FastAPI
|
|
233
|
+
application. It keeps error contracts alongside their feature routes and merges
|
|
234
|
+
them once at the composition root.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# fastapi-canon
|
|
2
|
+
|
|
3
|
+
`fastapi-canon` is an opinionated composition library for feature-oriented
|
|
4
|
+
FastAPI applications. A feature groups its routers, Dishka providers, error
|
|
5
|
+
contracts, exception handlers, and lifespan into one immutable value. The
|
|
6
|
+
application installs an explicitly ordered set of those values at its
|
|
7
|
+
composition root.
|
|
8
|
+
|
|
9
|
+
Dishka is a deliberate part of this canon, not an optional integration.
|
|
10
|
+
`fastapi-canon` defines one dependency-injection approach: features contribute
|
|
11
|
+
Dishka providers, and the composition builds and owns one shared Dishka
|
|
12
|
+
container. Applications that choose another dependency-injection framework are
|
|
13
|
+
outside the library's intended architecture.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from fastapi import APIRouter, FastAPI
|
|
17
|
+
from fastapi_canon import Composition, Feature
|
|
18
|
+
|
|
19
|
+
projects = APIRouter(prefix="/projects", tags=["projects"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@projects.get("")
|
|
23
|
+
async def list_projects() -> list[str]:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
project_feature = Feature(routers=[projects])
|
|
28
|
+
|
|
29
|
+
app = Composition(project_feature).apply(FastAPI())
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Contributions
|
|
33
|
+
|
|
34
|
+
Every contribution is optional:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
feature = Feature(
|
|
38
|
+
routers=[router],
|
|
39
|
+
providers=[provider],
|
|
40
|
+
errors=feature_errors,
|
|
41
|
+
exception_handlers=[handler_spec],
|
|
42
|
+
lifespan=feature_lifespan,
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Mutable sequences passed to `Feature` are copied to tuples. Installing features
|
|
47
|
+
preserves declaration order for routes and startup. Shutdown runs in reverse
|
|
48
|
+
order, including cleanup of features that started before a later feature failed.
|
|
49
|
+
|
|
50
|
+
### Dishka
|
|
51
|
+
|
|
52
|
+
Dishka is a required runtime dependency and the canonical dependency-injection
|
|
53
|
+
mechanism. Provider instances from every feature are validated together and
|
|
54
|
+
used to build one `AsyncContainer`. Applying the composition configures
|
|
55
|
+
Dishka's FastAPI middleware and closes the container during application
|
|
56
|
+
shutdown. Dishka exposes the container as `app.state.dishka_container`.
|
|
57
|
+
|
|
58
|
+
### Errors
|
|
59
|
+
|
|
60
|
+
Error contracts are implemented directly by `fastapi-canon`; no separate error
|
|
61
|
+
library is required. Each feature may expose one `ErrorRegistry`. Registries are
|
|
62
|
+
merged and installed once, so runtime RFC 9457 Problem Details and OpenAPI use
|
|
63
|
+
the same definitions:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from fastapi_canon import Composition, Error, ErrorOptions, ErrorRegistry, Feature
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProjectNotFound(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
project_not_found = Error(
|
|
74
|
+
ProjectNotFound,
|
|
75
|
+
status=404,
|
|
76
|
+
code="project_not_found",
|
|
77
|
+
title="Project not found",
|
|
78
|
+
detail=lambda error: str(error),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
project_errors = ErrorRegistry(
|
|
82
|
+
name="projects",
|
|
83
|
+
errors=[project_not_found],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
project_feature = Feature(
|
|
87
|
+
routers=[projects],
|
|
88
|
+
errors=project_errors,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
app = Composition(
|
|
92
|
+
project_feature,
|
|
93
|
+
errors=ErrorOptions(
|
|
94
|
+
type_base="https://api.example.com/problems",
|
|
95
|
+
),
|
|
96
|
+
).apply(FastAPI())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Declare endpoint responses from the same registry used at runtime:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
@projects.get(
|
|
103
|
+
"/{project_id}",
|
|
104
|
+
responses=project_errors.responses(project_not_found),
|
|
105
|
+
)
|
|
106
|
+
async def get_project(project_id: str) -> dict[str, str]:
|
|
107
|
+
raise ProjectNotFound(project_id)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### OpenAPI representation
|
|
111
|
+
|
|
112
|
+
Every declared error becomes a reusable schema in `components.schemas`. The
|
|
113
|
+
corresponding operation references it as an `application/problem+json`
|
|
114
|
+
response:
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
paths:
|
|
118
|
+
/projects/{project_id}:
|
|
119
|
+
get:
|
|
120
|
+
responses:
|
|
121
|
+
"404":
|
|
122
|
+
description: Project not found
|
|
123
|
+
content:
|
|
124
|
+
application/problem+json:
|
|
125
|
+
schema:
|
|
126
|
+
$ref: "#/components/schemas/ProjectNotFoundProblem"
|
|
127
|
+
|
|
128
|
+
components:
|
|
129
|
+
schemas:
|
|
130
|
+
ProjectNotFoundProblem:
|
|
131
|
+
type: object
|
|
132
|
+
required: [type, title, status, code]
|
|
133
|
+
properties:
|
|
134
|
+
type:
|
|
135
|
+
type: string
|
|
136
|
+
const: https://api.example.com/problems/project_not_found
|
|
137
|
+
title:
|
|
138
|
+
type: string
|
|
139
|
+
const: Project not found
|
|
140
|
+
status:
|
|
141
|
+
type: integer
|
|
142
|
+
const: 404
|
|
143
|
+
code:
|
|
144
|
+
type: string
|
|
145
|
+
const: project_not_found
|
|
146
|
+
detail:
|
|
147
|
+
type: [string, "null"]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Typed extension fields and documented response headers are added to that same
|
|
151
|
+
schema and response. If multiple errors share one status code, the response
|
|
152
|
+
uses `oneOf` with `code` as its discriminator. When validation normalization is
|
|
153
|
+
enabled, FastAPI's default `422` response is replaced by
|
|
154
|
+
`RequestValidationProblem` using the same media type.
|
|
155
|
+
|
|
156
|
+
When all local registries already share a `type_base`, it is inferred. The
|
|
157
|
+
`ErrorOptions` settings `include_validation_error`,
|
|
158
|
+
`include_http_exceptions`, and `include_unhandled_error` are passed to
|
|
159
|
+
the integrated error engine and default to `True`.
|
|
160
|
+
|
|
161
|
+
Use `ExceptionHandlerSpec` for a deliberately custom Starlette/FastAPI handler:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from fastapi_canon import ExceptionHandlerSpec
|
|
165
|
+
|
|
166
|
+
feature = Feature(
|
|
167
|
+
exception_handlers=[ExceptionHandlerSpec(DomainError, domain_error_handler)],
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Installation guarantees
|
|
172
|
+
|
|
173
|
+
- Feature order is explicit and deterministic.
|
|
174
|
+
- Reinstalling the exact same feature objects with the same options is a no-op.
|
|
175
|
+
- A different second installation is rejected.
|
|
176
|
+
- Duplicate routers, providers, handlers, and error collisions fail during
|
|
177
|
+
configuration.
|
|
178
|
+
- Known configuration errors are validated against a temporary application
|
|
179
|
+
before the real application is changed.
|
|
180
|
+
- Provider-backed features must be installed before the application starts.
|
|
181
|
+
- Disabling a feature means omitting it from `Composition`, which removes all of
|
|
182
|
+
its contributions together.
|
|
183
|
+
|
|
184
|
+
Configuration failures raise `FeatureConfigurationError`.
|
|
185
|
+
|
|
186
|
+
## Requirements
|
|
187
|
+
|
|
188
|
+
- CPython 3.12, 3.13, or 3.14
|
|
189
|
+
- FastAPI 0.115 or newer, below 1.0
|
|
190
|
+
- Dishka 1.10 or newer, below 2.0
|
|
191
|
+
- Pydantic 2.9 or newer, below 3.0
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
Install the development dependencies and run the quality gates:
|
|
196
|
+
|
|
197
|
+
```console
|
|
198
|
+
uv sync --all-groups
|
|
199
|
+
uv run pre-commit install --config .pre-commit-config.yml
|
|
200
|
+
uv run pre-commit run --config .pre-commit-config.yml --all-files
|
|
201
|
+
uv run ruff format --check .
|
|
202
|
+
uv run ruff check .
|
|
203
|
+
uv run mypy
|
|
204
|
+
uv run pytest
|
|
205
|
+
uv build
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Showcase
|
|
209
|
+
|
|
210
|
+
See [`examples/showcase`](examples/showcase) for a runnable two-feature FastAPI
|
|
211
|
+
application. It keeps error contracts alongside their feature routes and merges
|
|
212
|
+
them once at the composition root.
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# fastapi-canon showcase
|
|
2
|
+
|
|
3
|
+
This small application demonstrates the recommended application boundary:
|
|
4
|
+
each feature owns its router and its domain-error contracts, while `app.py`
|
|
5
|
+
is the only composition root. Its layout is deliberately suitable as a starting
|
|
6
|
+
point for a real service:
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
showcase/
|
|
10
|
+
app.py # composition root
|
|
11
|
+
features/
|
|
12
|
+
catalog/
|
|
13
|
+
__init__.py # public `feature` export
|
|
14
|
+
router.py # HTTP layer
|
|
15
|
+
models.py # request and response models
|
|
16
|
+
exceptions.py # domain exceptions, without HTTP concerns
|
|
17
|
+
errors.py # Error / ErrorRegistry contracts
|
|
18
|
+
service.py # framework-independent use cases
|
|
19
|
+
providers.py # Dishka bindings contributed by the feature
|
|
20
|
+
status/
|
|
21
|
+
__init__.py
|
|
22
|
+
router.py
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run it from the repository root:
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
uv run --with uvicorn uvicorn examples.showcase.app:app --reload
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then open [the API documentation](http://127.0.0.1:8000/docs), or try the
|
|
32
|
+
following requests:
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
curl http://127.0.0.1:8000/health
|
|
36
|
+
curl http://127.0.0.1:8000/products/coffee
|
|
37
|
+
curl http://127.0.0.1:8000/products/missing
|
|
38
|
+
curl -X POST http://127.0.0.1:8000/products/tea/reservations -H "content-type: application/json" -d '{"quantity": 1}'
|
|
39
|
+
curl -X POST http://127.0.0.1:8000/products/coffee/reservations -H "content-type: application/json" -d '{"quantity": 0}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The last three calls show the three error classes handled by the shared error
|
|
43
|
+
engine:
|
|
44
|
+
|
|
45
|
+
| Situation | Result |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Unknown product | Feature-defined `404 product_not_found` problem |
|
|
48
|
+
| Insufficient stock | Feature-defined `409 product_unavailable` problem, including typed extension members |
|
|
49
|
+
| Invalid request body | Built-in normalized `422 request_validation_error` problem |
|
|
50
|
+
|
|
51
|
+
`catalog/errors.py` deliberately has no application-wide problem-type URL. Its
|
|
52
|
+
`ErrorRegistry` is local to the feature. `Composition` merges that registry
|
|
53
|
+
and `ErrorOptions(type_base=...)` resolves every feature error type to the
|
|
54
|
+
same public namespace. The `responses=errors.responses(...)` declarations use
|
|
55
|
+
the very same contracts as runtime handling, so `/openapi.json` documents the
|
|
56
|
+
`404` and `409` responses without duplicating schemas.
|
|
57
|
+
|
|
58
|
+
The router only validates HTTP input and calls `CatalogService`; it receives
|
|
59
|
+
that service through Dishka. `catalog/__init__.py` contributes the provider,
|
|
60
|
+
router, and error registry as one `Feature`, so removing the feature removes
|
|
61
|
+
all of those pieces together.
|
|
62
|
+
|
|
63
|
+
To turn a feature off, remove `catalog_feature` from `Composition` in `app.py`.
|
|
64
|
+
Its routes and error definitions disappear together.
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
|
|
3
|
+
from examples.showcase.features.catalog import feature as catalog_feature
|
|
4
|
+
from examples.showcase.features.status import feature as status_feature
|
|
5
|
+
from fastapi_canon import Composition, ErrorOptions
|
|
6
|
+
|
|
7
|
+
composition = Composition(
|
|
8
|
+
status_feature,
|
|
9
|
+
catalog_feature,
|
|
10
|
+
errors=ErrorOptions(type_base="https://api.example.test/problems"),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
app = composition.apply(
|
|
14
|
+
FastAPI(
|
|
15
|
+
title="fastapi-canon showcase",
|
|
16
|
+
version="0.1.0",
|
|
17
|
+
description="Feature composition with RFC 9457 Problem Details.",
|
|
18
|
+
)
|
|
19
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from fastapi_canon import Feature
|
|
2
|
+
|
|
3
|
+
from .errors import registry
|
|
4
|
+
from .providers import CatalogProvider
|
|
5
|
+
from .router import router
|
|
6
|
+
|
|
7
|
+
feature = Feature(
|
|
8
|
+
routers=[router],
|
|
9
|
+
providers=[CatalogProvider()],
|
|
10
|
+
errors=registry,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ["feature"]
|