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.
@@ -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"