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.
Files changed (50) hide show
  1. fastapi_canon-0.1.0/.github/workflows/ci.yml +107 -0
  2. fastapi_canon-0.1.0/.gitignore +12 -0
  3. fastapi_canon-0.1.0/.pre-commit-config.yml +17 -0
  4. fastapi_canon-0.1.0/.python-version +1 -0
  5. fastapi_canon-0.1.0/PKG-INFO +234 -0
  6. fastapi_canon-0.1.0/README.md +212 -0
  7. fastapi_canon-0.1.0/examples/__init__.py +0 -0
  8. fastapi_canon-0.1.0/examples/showcase/README.md +64 -0
  9. fastapi_canon-0.1.0/examples/showcase/__init__.py +0 -0
  10. fastapi_canon-0.1.0/examples/showcase/app.py +19 -0
  11. fastapi_canon-0.1.0/examples/showcase/features/__init__.py +0 -0
  12. fastapi_canon-0.1.0/examples/showcase/features/catalog/__init__.py +13 -0
  13. fastapi_canon-0.1.0/examples/showcase/features/catalog/errors.py +38 -0
  14. fastapi_canon-0.1.0/examples/showcase/features/catalog/exceptions.py +13 -0
  15. fastapi_canon-0.1.0/examples/showcase/features/catalog/models.py +10 -0
  16. fastapi_canon-0.1.0/examples/showcase/features/catalog/providers.py +10 -0
  17. fastapi_canon-0.1.0/examples/showcase/features/catalog/router.py +31 -0
  18. fastapi_canon-0.1.0/examples/showcase/features/catalog/service.py +25 -0
  19. fastapi_canon-0.1.0/examples/showcase/features/status/__init__.py +7 -0
  20. fastapi_canon-0.1.0/examples/showcase/features/status/router.py +8 -0
  21. fastapi_canon-0.1.0/fastapi_canon/__init__.py +22 -0
  22. fastapi_canon-0.1.0/fastapi_canon/error/__init__.py +11 -0
  23. fastapi_canon-0.1.0/fastapi_canon/error/contracts.py +71 -0
  24. fastapi_canon-0.1.0/fastapi_canon/error/error.py +273 -0
  25. fastapi_canon-0.1.0/fastapi_canon/error/handlers.py +277 -0
  26. fastapi_canon-0.1.0/fastapi_canon/error/openapi.py +437 -0
  27. fastapi_canon-0.1.0/fastapi_canon/error/problem.py +96 -0
  28. fastapi_canon-0.1.0/fastapi_canon/error/registry.py +273 -0
  29. fastapi_canon-0.1.0/fastapi_canon/error/rendering.py +91 -0
  30. fastapi_canon-0.1.0/fastapi_canon/error/types.py +73 -0
  31. fastapi_canon-0.1.0/fastapi_canon/error/validation.py +18 -0
  32. fastapi_canon-0.1.0/fastapi_canon/feature.py +395 -0
  33. fastapi_canon-0.1.0/fastapi_canon/py.typed +0 -0
  34. fastapi_canon-0.1.0/pyproject.toml +69 -0
  35. fastapi_canon-0.1.0/tests/error/__init__.py +0 -0
  36. fastapi_canon-0.1.0/tests/error/contract/__init__.py +0 -0
  37. fastapi_canon-0.1.0/tests/error/contract/test_openapi.py +360 -0
  38. fastapi_canon-0.1.0/tests/error/integration/__init__.py +0 -0
  39. fastapi_canon-0.1.0/tests/error/integration/test_handlers.py +311 -0
  40. fastapi_canon-0.1.0/tests/error/integration/test_http_exceptions.py +88 -0
  41. fastapi_canon-0.1.0/tests/error/integration/test_request_validation.py +90 -0
  42. fastapi_canon-0.1.0/tests/error/unit/__init__.py +0 -0
  43. fastapi_canon-0.1.0/tests/error/unit/test_error.py +215 -0
  44. fastapi_canon-0.1.0/tests/error/unit/test_modern_python.py +36 -0
  45. fastapi_canon-0.1.0/tests/error/unit/test_package.py +14 -0
  46. fastapi_canon-0.1.0/tests/error/unit/test_problem.py +93 -0
  47. fastapi_canon-0.1.0/tests/error/unit/test_registry.py +233 -0
  48. fastapi_canon-0.1.0/tests/error/unit/test_rendering.py +146 -0
  49. fastapi_canon-0.1.0/tests/test_features.py +332 -0
  50. 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,12 @@
1
+ .coverage
2
+ .mypy_cache/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ .wheel-venv/
7
+ __pycache__/
8
+ *.egg-info/
9
+ *.py[cod]
10
+ build/
11
+ dist/
12
+ htmlcov/
@@ -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
+ )
@@ -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"]