csrd-service 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.
- csrd_service-0.1.0/.gitignore +217 -0
- csrd_service-0.1.0/PKG-INFO +11 -0
- csrd_service-0.1.0/README.md +98 -0
- csrd_service-0.1.0/pyproject.toml +26 -0
- csrd_service-0.1.0/src/csrd/service/__init__.py +34 -0
- csrd_service-0.1.0/src/csrd/service/_base_service.py +66 -0
- csrd_service-0.1.0/src/csrd/service/_errors.py +135 -0
- csrd_service-0.1.0/src/csrd/service/_exception_handler.py +73 -0
- csrd_service-0.1.0/src/csrd/service/py.typed +0 -0
- csrd_service-0.1.0/tests/test_service.py +194 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
/env/
|
|
142
|
+
/venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
*.db
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Import linter cache
|
|
213
|
+
.import_linter_cache/
|
|
214
|
+
|
|
215
|
+
# IDE
|
|
216
|
+
.idea/
|
|
217
|
+
.idea/*
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-service
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Service layer base class with domain error hierarchy
|
|
5
|
+
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
|
+
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/service
|
|
7
|
+
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: csrd-context
|
|
11
|
+
Requires-Dist: csrd-models
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# csrd.service
|
|
2
|
+
|
|
3
|
+
Service-layer boilerplate for the **CSRD** pattern (Controller → Service → Repository / Delegate).
|
|
4
|
+
|
|
5
|
+
## What it provides
|
|
6
|
+
|
|
7
|
+
| Component | Purpose |
|
|
8
|
+
|-----------|---------|
|
|
9
|
+
| `BaseService` | Lightweight base class with per-class logger and request-context accessors (`current_user()`, `current_request_id()`) |
|
|
10
|
+
| `ServiceError` | Base domain exception — keeps business logic free of `HTTPException` |
|
|
11
|
+
| `NotFoundError` | 404 — resource doesn't exist |
|
|
12
|
+
| `ConflictError` | 409 — duplicate, version mismatch, etc. |
|
|
13
|
+
| `ValidationError` | 422 — business-rule validation (not schema validation) |
|
|
14
|
+
| `AuthorizationError` | 403 — authenticated user lacks permission |
|
|
15
|
+
| `DownstreamError` | 502 — a delegate call to another service failed |
|
|
16
|
+
| `service_exception_handler` | FastAPI handler that maps any `ServiceError` → structured `APIErrorResponse` JSON |
|
|
17
|
+
|
|
18
|
+
## Dependency tier
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Tier 1 (standalone) csrd.models · csrd.lifespan · csrd.context
|
|
22
|
+
Tier 2 (uses Tier 1) csrd.delegate · csrd.repository · csrd.service ← NEW
|
|
23
|
+
Tier 3 (uses Tier 1+2) csrd.versioning
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`csrd.service` depends on `csrd.context` and `csrd.models` only. It does **not** import `csrd.repository` or `csrd.delegate` — your services accept those as constructor params.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from csrd.service import BaseService, NotFoundError, DownstreamError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OrderService(BaseService):
|
|
35
|
+
def __init__(self, repo: OrderRepository, payments: PaymentsDelegate):
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._repo = repo
|
|
38
|
+
self._payments = payments
|
|
39
|
+
|
|
40
|
+
async def get_order(self, order_id: int) -> Order:
|
|
41
|
+
order = await self._repo.get_by_id(order_id)
|
|
42
|
+
if order is None:
|
|
43
|
+
raise NotFoundError("Order not found", detail=f"order_id={order_id}")
|
|
44
|
+
return order
|
|
45
|
+
|
|
46
|
+
async def place_order(self, cart: Cart) -> Order:
|
|
47
|
+
order = await self._repo.create(cart)
|
|
48
|
+
try:
|
|
49
|
+
await self._payments.charge(order)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
raise DownstreamError("Payment failed", cause=exc) from exc
|
|
52
|
+
return order
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Register the exception handler
|
|
56
|
+
|
|
57
|
+
With `csrd.versioning`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from csrd.service import ServiceError, service_exception_handler
|
|
61
|
+
|
|
62
|
+
configure_versioned_api(
|
|
63
|
+
app,
|
|
64
|
+
version_mapping=VERSIONS,
|
|
65
|
+
ex_handlers=[(ServiceError, service_exception_handler)],
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or on a plain FastAPI app:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
app.add_exception_handler(ServiceError, service_exception_handler)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Wire into FastAPI DI
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from functools import cache
|
|
79
|
+
from typing import Annotated
|
|
80
|
+
from fastapi import Depends
|
|
81
|
+
|
|
82
|
+
@cache
|
|
83
|
+
def order_service_factory(repo: OrderRepository, payments: PaymentsDelegate):
|
|
84
|
+
return OrderService(repo, payments)
|
|
85
|
+
|
|
86
|
+
OrderServiceDep = Annotated[OrderService, Depends(order_service_factory)]
|
|
87
|
+
|
|
88
|
+
# In your endpoint:
|
|
89
|
+
@router.get("/orders/{order_id}")
|
|
90
|
+
async def get_order(service: OrderServiceDep, order_id: int):
|
|
91
|
+
return await service.get_order(order_id)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Installation
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv add csrd-service # or: pip install csrd-service
|
|
98
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-service"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Service layer base class with domain error hierarchy"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"csrd-context",
|
|
9
|
+
"csrd-models",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.uv.sources]
|
|
13
|
+
csrd-context = { workspace = true }
|
|
14
|
+
csrd-models = { workspace = true }
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
18
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/service"
|
|
19
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/csrd"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Service layer boilerplate for the CSRD pattern.
|
|
2
|
+
|
|
3
|
+
The ``csrd.service`` package provides:
|
|
4
|
+
|
|
5
|
+
* :class:`BaseService` — lightweight base with context accessors.
|
|
6
|
+
* **Domain errors** — :class:`ServiceError` hierarchy
|
|
7
|
+
(:class:`NotFoundError`, :class:`ConflictError`, :class:`ValidationError`,
|
|
8
|
+
:class:`AuthorizationError`, :class:`DownstreamError`) that keep business
|
|
9
|
+
logic free of ``HTTPException``.
|
|
10
|
+
* :func:`service_exception_handler` — FastAPI handler that maps any
|
|
11
|
+
``ServiceError`` to a structured ``APIErrorResponse`` JSON body.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._base_service import BaseService
|
|
15
|
+
from ._errors import (
|
|
16
|
+
AuthorizationError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
DownstreamError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
ServiceError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from ._exception_handler import service_exception_handler
|
|
24
|
+
|
|
25
|
+
__all__ = (
|
|
26
|
+
"AuthorizationError",
|
|
27
|
+
"BaseService",
|
|
28
|
+
"ConflictError",
|
|
29
|
+
"DownstreamError",
|
|
30
|
+
"NotFoundError",
|
|
31
|
+
"ServiceError",
|
|
32
|
+
"ValidationError",
|
|
33
|
+
"service_exception_handler",
|
|
34
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Lightweight base class for service-layer components.
|
|
2
|
+
|
|
3
|
+
``BaseService`` provides:
|
|
4
|
+
|
|
5
|
+
* Accessor helpers for the current request context (user claims, hit-id)
|
|
6
|
+
via ``csrd.context``.
|
|
7
|
+
* A thin wrapper so subclasses can declare their repositories / delegates
|
|
8
|
+
as plain constructor params — no magic, just a convenient base.
|
|
9
|
+
|
|
10
|
+
For logging, compose with ``LoggingMixin`` from ``csrd.logging``::
|
|
11
|
+
|
|
12
|
+
from csrd.logging import LoggingMixin
|
|
13
|
+
|
|
14
|
+
class OrderService(BaseService, LoggingMixin, auto_log=True):
|
|
15
|
+
def __init__(self, repo: OrderRepository, payments: PaymentsDelegate):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._repo = repo
|
|
18
|
+
self._payments = payments
|
|
19
|
+
|
|
20
|
+
async def place_order(self, cart: Cart) -> Order:
|
|
21
|
+
order = await self._repo.create(cart)
|
|
22
|
+
try:
|
|
23
|
+
await self._payments.charge(order)
|
|
24
|
+
except HTTPException as exc:
|
|
25
|
+
raise DownstreamError(
|
|
26
|
+
"Payment failed", detail=str(exc.detail), cause=exc
|
|
27
|
+
) from exc
|
|
28
|
+
return order
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
from csrd.context import get_hit_id
|
|
36
|
+
from csrd.context.platform import user_info_context
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from csrd.models.claims import UserClaims
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseService:
|
|
43
|
+
"""Base class for service-layer components.
|
|
44
|
+
|
|
45
|
+
Provides contextual accessors for the current request. For logging,
|
|
46
|
+
compose with :class:`csrd.logging.LoggingMixin`.
|
|
47
|
+
|
|
48
|
+
Subclasses should accept their repositories and delegates via
|
|
49
|
+
``__init__`` — ``BaseService`` intentionally knows nothing about
|
|
50
|
+
those Tier-2 types so that it stays in Tier 2 itself.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# ── Context helpers ──────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def current_user() -> UserClaims | None:
|
|
57
|
+
"""Return the authenticated user's claims for the current request, or ``None``."""
|
|
58
|
+
return user_info_context.get() # type: ignore[return-value]
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def current_request_id() -> str | None:
|
|
62
|
+
"""Return the current request's hit-id, or ``None``."""
|
|
63
|
+
return get_hit_id()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ("BaseService",)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Domain error hierarchy for the service layer.
|
|
2
|
+
|
|
3
|
+
Services raise these instead of ``HTTPException`` — keeping business logic
|
|
4
|
+
transport-agnostic. The versioning framework's exception handler (or a
|
|
5
|
+
standalone handler registered via ``service_exception_handler``) maps them
|
|
6
|
+
to structured ``APIErrorResponse`` JSON automatically.
|
|
7
|
+
|
|
8
|
+
Each error carries:
|
|
9
|
+
|
|
10
|
+
* ``message`` — human-readable summary (becomes the ``meta.error`` field)
|
|
11
|
+
* ``detail`` — optional extended description (becomes ``errors[0].detail``)
|
|
12
|
+
* ``code`` — optional machine-readable error code (e.g. ``"DUPLICATE_EMAIL"``)
|
|
13
|
+
* ``status_code`` — HTTP status the handler will use (class-level default,
|
|
14
|
+
overridable per-instance)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ServiceError(Exception):
|
|
21
|
+
"""Base class for all domain/service-layer errors.
|
|
22
|
+
|
|
23
|
+
Subclasses set ``status_code`` as a class attribute. Individual
|
|
24
|
+
raises can override via the constructor keyword.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
status_code: int = 500
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str = "Internal service error",
|
|
32
|
+
*,
|
|
33
|
+
detail: str | None = None,
|
|
34
|
+
code: str | None = None,
|
|
35
|
+
status_code: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.message = message
|
|
39
|
+
self.detail = detail
|
|
40
|
+
self.code = code
|
|
41
|
+
if status_code is not None:
|
|
42
|
+
self.status_code = status_code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotFoundError(ServiceError):
|
|
46
|
+
"""The requested resource does not exist."""
|
|
47
|
+
|
|
48
|
+
status_code: int = 404
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str = "Resource not found",
|
|
53
|
+
*,
|
|
54
|
+
detail: str | None = None,
|
|
55
|
+
code: str | None = None,
|
|
56
|
+
status_code: int | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(message, detail=detail, code=code, status_code=status_code)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConflictError(ServiceError):
|
|
62
|
+
"""A write was rejected due to a conflict (duplicate, version mismatch, …)."""
|
|
63
|
+
|
|
64
|
+
status_code: int = 409
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str = "Conflict",
|
|
69
|
+
*,
|
|
70
|
+
detail: str | None = None,
|
|
71
|
+
code: str | None = None,
|
|
72
|
+
status_code: int | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(message, detail=detail, code=code, status_code=status_code)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ValidationError(ServiceError):
|
|
78
|
+
"""Business-rule validation failed (distinct from request-schema validation)."""
|
|
79
|
+
|
|
80
|
+
status_code: int = 422
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
message: str = "Validation failed",
|
|
85
|
+
*,
|
|
86
|
+
detail: str | None = None,
|
|
87
|
+
code: str | None = None,
|
|
88
|
+
status_code: int | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__(message, detail=detail, code=code, status_code=status_code)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AuthorizationError(ServiceError):
|
|
94
|
+
"""The authenticated user is not allowed to perform this action."""
|
|
95
|
+
|
|
96
|
+
status_code: int = 403
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
message: str = "Forbidden",
|
|
101
|
+
*,
|
|
102
|
+
detail: str | None = None,
|
|
103
|
+
code: str | None = None,
|
|
104
|
+
status_code: int | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(message, detail=detail, code=code, status_code=status_code)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DownstreamError(ServiceError):
|
|
110
|
+
"""A call to a downstream service (via a delegate) failed."""
|
|
111
|
+
|
|
112
|
+
status_code: int = 502
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
message: str = "Downstream service error",
|
|
117
|
+
*,
|
|
118
|
+
detail: str | None = None,
|
|
119
|
+
code: str | None = None,
|
|
120
|
+
status_code: int | None = None,
|
|
121
|
+
cause: Exception | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
super().__init__(message, detail=detail, code=code, status_code=status_code)
|
|
124
|
+
if cause is not None:
|
|
125
|
+
self.__cause__ = cause
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = (
|
|
129
|
+
"AuthorizationError",
|
|
130
|
+
"ConflictError",
|
|
131
|
+
"DownstreamError",
|
|
132
|
+
"NotFoundError",
|
|
133
|
+
"ServiceError",
|
|
134
|
+
"ValidationError",
|
|
135
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""FastAPI exception handler that maps ``ServiceError`` to ``APIErrorResponse``.
|
|
2
|
+
|
|
3
|
+
Register with ``configure_versioned_api``::
|
|
4
|
+
|
|
5
|
+
from csrd.service import ServiceError, service_exception_handler
|
|
6
|
+
|
|
7
|
+
configure_versioned_api(
|
|
8
|
+
app,
|
|
9
|
+
version_mapping=VERSIONS,
|
|
10
|
+
ex_handlers=[(ServiceError, service_exception_handler)],
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Or register directly on a plain FastAPI app::
|
|
14
|
+
|
|
15
|
+
app.add_exception_handler(ServiceError, service_exception_handler)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import datetime
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import UTC
|
|
24
|
+
|
|
25
|
+
from fastapi import Request
|
|
26
|
+
from fastapi.responses import JSONResponse
|
|
27
|
+
|
|
28
|
+
from csrd.models.errors import APIErrorResponse, APIVersion, Error, ErrorMeta
|
|
29
|
+
|
|
30
|
+
from ._errors import ServiceError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_request_id(request: Request) -> str:
|
|
34
|
+
"""Best-effort extraction of request ID from scope, falling back to a UUID."""
|
|
35
|
+
try:
|
|
36
|
+
return str(request.scope["_request_context"]["hit_id"])
|
|
37
|
+
except (KeyError, TypeError):
|
|
38
|
+
return str(uuid.uuid4())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_api_version(request: Request) -> APIVersion:
|
|
42
|
+
served = None
|
|
43
|
+
with contextlib.suppress(AttributeError, TypeError):
|
|
44
|
+
served = request.scope.get("api_version") # type: ignore[union-attr]
|
|
45
|
+
return APIVersion(served=served)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def service_exception_handler(request: Request, exc: ServiceError) -> JSONResponse:
|
|
49
|
+
"""Convert a :class:`ServiceError` into a structured ``APIErrorResponse``."""
|
|
50
|
+
errors: list[Error] = []
|
|
51
|
+
if exc.detail or exc.code:
|
|
52
|
+
errors.append(Error(title=exc.message, detail=exc.detail, code=exc.code))
|
|
53
|
+
|
|
54
|
+
body = APIErrorResponse(
|
|
55
|
+
meta=ErrorMeta(
|
|
56
|
+
status=exc.status_code,
|
|
57
|
+
error=exc.message,
|
|
58
|
+
method=request.method,
|
|
59
|
+
path=request.url.path,
|
|
60
|
+
request_id=_get_request_id(request),
|
|
61
|
+
timestamp=datetime.datetime.now(UTC),
|
|
62
|
+
api_version=_get_api_version(request),
|
|
63
|
+
),
|
|
64
|
+
errors=errors,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return JSONResponse(
|
|
68
|
+
status_code=exc.status_code,
|
|
69
|
+
content=body.as_dict(by_alias=True, mode="json"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ("service_exception_handler",)
|
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Tests for csrd.service — errors, BaseService, and exception handler."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from csrd.service import (
|
|
8
|
+
AuthorizationError,
|
|
9
|
+
BaseService,
|
|
10
|
+
ConflictError,
|
|
11
|
+
DownstreamError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ServiceError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
service_exception_handler,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ── Domain Errors ────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestServiceError:
|
|
22
|
+
def test_default_message(self):
|
|
23
|
+
err = ServiceError()
|
|
24
|
+
assert str(err) == "Internal service error"
|
|
25
|
+
assert err.status_code == 500
|
|
26
|
+
|
|
27
|
+
def test_custom_message(self):
|
|
28
|
+
err = ServiceError("boom", detail="something broke", code="ERR_001")
|
|
29
|
+
assert err.message == "boom"
|
|
30
|
+
assert err.detail == "something broke"
|
|
31
|
+
assert err.code == "ERR_001"
|
|
32
|
+
|
|
33
|
+
def test_status_code_override(self):
|
|
34
|
+
err = ServiceError("custom", status_code=418)
|
|
35
|
+
assert err.status_code == 418
|
|
36
|
+
|
|
37
|
+
def test_is_exception(self):
|
|
38
|
+
with pytest.raises(ServiceError):
|
|
39
|
+
raise ServiceError("test")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestNotFoundError:
|
|
43
|
+
def test_defaults(self):
|
|
44
|
+
err = NotFoundError()
|
|
45
|
+
assert err.status_code == 404
|
|
46
|
+
assert err.message == "Resource not found"
|
|
47
|
+
|
|
48
|
+
def test_custom(self):
|
|
49
|
+
err = NotFoundError("Order not found", detail="order_id=42", code="ORDER_MISSING")
|
|
50
|
+
assert err.status_code == 404
|
|
51
|
+
assert err.message == "Order not found"
|
|
52
|
+
assert err.detail == "order_id=42"
|
|
53
|
+
assert err.code == "ORDER_MISSING"
|
|
54
|
+
|
|
55
|
+
def test_is_service_error(self):
|
|
56
|
+
assert isinstance(NotFoundError(), ServiceError)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestConflictError:
|
|
60
|
+
def test_defaults(self):
|
|
61
|
+
err = ConflictError()
|
|
62
|
+
assert err.status_code == 409
|
|
63
|
+
assert err.message == "Conflict"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestValidationError:
|
|
67
|
+
def test_defaults(self):
|
|
68
|
+
err = ValidationError()
|
|
69
|
+
assert err.status_code == 422
|
|
70
|
+
assert err.message == "Validation failed"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestAuthorizationError:
|
|
74
|
+
def test_defaults(self):
|
|
75
|
+
err = AuthorizationError()
|
|
76
|
+
assert err.status_code == 403
|
|
77
|
+
assert err.message == "Forbidden"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestDownstreamError:
|
|
81
|
+
def test_defaults(self):
|
|
82
|
+
err = DownstreamError()
|
|
83
|
+
assert err.status_code == 502
|
|
84
|
+
assert err.message == "Downstream service error"
|
|
85
|
+
|
|
86
|
+
def test_with_cause(self):
|
|
87
|
+
cause = ValueError("connection refused")
|
|
88
|
+
err = DownstreamError("Payment service down", cause=cause)
|
|
89
|
+
assert err.__cause__ is cause
|
|
90
|
+
|
|
91
|
+
def test_subclass_hierarchy(self):
|
|
92
|
+
"""All errors should be catchable as ServiceError."""
|
|
93
|
+
for cls in (
|
|
94
|
+
NotFoundError,
|
|
95
|
+
ConflictError,
|
|
96
|
+
ValidationError,
|
|
97
|
+
AuthorizationError,
|
|
98
|
+
DownstreamError,
|
|
99
|
+
):
|
|
100
|
+
assert issubclass(cls, ServiceError)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── BaseService ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestBaseService:
|
|
107
|
+
def test_current_user_none_outside_request(self):
|
|
108
|
+
svc = BaseService()
|
|
109
|
+
assert svc.current_user() is None
|
|
110
|
+
|
|
111
|
+
def test_current_request_id_none_outside_request(self):
|
|
112
|
+
svc = BaseService()
|
|
113
|
+
assert svc.current_request_id() is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Exception Handler ────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestServiceExceptionHandler:
|
|
120
|
+
def _make_app(self) -> FastAPI:
|
|
121
|
+
app = FastAPI()
|
|
122
|
+
app.add_exception_handler(ServiceError, service_exception_handler) # type: ignore[arg-type]
|
|
123
|
+
|
|
124
|
+
@app.get("/not-found")
|
|
125
|
+
async def _not_found():
|
|
126
|
+
raise NotFoundError("User not found", detail="id=99", code="USER_MISSING")
|
|
127
|
+
|
|
128
|
+
@app.get("/conflict")
|
|
129
|
+
async def _conflict():
|
|
130
|
+
raise ConflictError("Duplicate email")
|
|
131
|
+
|
|
132
|
+
@app.get("/forbidden")
|
|
133
|
+
async def _forbidden():
|
|
134
|
+
raise AuthorizationError()
|
|
135
|
+
|
|
136
|
+
@app.get("/downstream")
|
|
137
|
+
async def _downstream():
|
|
138
|
+
raise DownstreamError("Payments unavailable", status_code=503)
|
|
139
|
+
|
|
140
|
+
@app.get("/bare")
|
|
141
|
+
async def _bare():
|
|
142
|
+
raise ServiceError()
|
|
143
|
+
|
|
144
|
+
return app
|
|
145
|
+
|
|
146
|
+
def test_not_found_response(self):
|
|
147
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
148
|
+
resp = client.get("/not-found")
|
|
149
|
+
assert resp.status_code == 404
|
|
150
|
+
body = resp.json()
|
|
151
|
+
assert body["meta"]["status"] == 404
|
|
152
|
+
assert body["meta"]["error"] == "User not found"
|
|
153
|
+
assert body["errors"][0]["detail"] == "id=99"
|
|
154
|
+
assert body["errors"][0]["code"] == "USER_MISSING"
|
|
155
|
+
|
|
156
|
+
def test_conflict_response(self):
|
|
157
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
158
|
+
resp = client.get("/conflict")
|
|
159
|
+
assert resp.status_code == 409
|
|
160
|
+
assert resp.json()["meta"]["error"] == "Duplicate email"
|
|
161
|
+
|
|
162
|
+
def test_forbidden_response(self):
|
|
163
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
164
|
+
resp = client.get("/forbidden")
|
|
165
|
+
assert resp.status_code == 403
|
|
166
|
+
|
|
167
|
+
def test_downstream_with_custom_status(self):
|
|
168
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
169
|
+
resp = client.get("/downstream")
|
|
170
|
+
assert resp.status_code == 503
|
|
171
|
+
|
|
172
|
+
def test_bare_service_error(self):
|
|
173
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
174
|
+
resp = client.get("/bare")
|
|
175
|
+
assert resp.status_code == 500
|
|
176
|
+
body = resp.json()
|
|
177
|
+
assert body["meta"]["error"] == "Internal service error"
|
|
178
|
+
# No detail/code → errors list should be empty
|
|
179
|
+
assert body["errors"] == []
|
|
180
|
+
|
|
181
|
+
def test_response_has_camel_case_keys(self):
|
|
182
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
183
|
+
resp = client.get("/not-found")
|
|
184
|
+
body = resp.json()
|
|
185
|
+
# meta fields should be camelCase
|
|
186
|
+
assert "requestId" in body["meta"]
|
|
187
|
+
assert "apiVersion" in body["meta"]
|
|
188
|
+
|
|
189
|
+
def test_response_has_method_and_path(self):
|
|
190
|
+
client = TestClient(self._make_app(), raise_server_exceptions=False)
|
|
191
|
+
resp = client.get("/not-found")
|
|
192
|
+
meta = resp.json()["meta"]
|
|
193
|
+
assert meta["method"] == "GET"
|
|
194
|
+
assert meta["path"] == "/not-found"
|