csrd-service 0.1.0__py3-none-any.whl
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/__init__.py +34 -0
- csrd/service/_base_service.py +66 -0
- csrd/service/_errors.py +135 -0
- csrd/service/_exception_handler.py +73 -0
- csrd/service/py.typed +0 -0
- csrd_service-0.1.0.dist-info/METADATA +11 -0
- csrd_service-0.1.0.dist-info/RECORD +8 -0
- csrd_service-0.1.0.dist-info/WHEEL +4 -0
csrd/service/__init__.py
ADDED
|
@@ -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",)
|
csrd/service/_errors.py
ADDED
|
@@ -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",)
|
csrd/service/py.typed
ADDED
|
File without changes
|
|
@@ -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,8 @@
|
|
|
1
|
+
csrd/service/__init__.py,sha256=F3qOtDjIUxjgrXIjZmN6Obwp7oWA9MScIacIonpd8eM,991
|
|
2
|
+
csrd/service/_base_service.py,sha256=KE_8GLXjAP4uuUtV_LIn2rgzsSAnVMuE4R2z303xw1c,2277
|
|
3
|
+
csrd/service/_errors.py,sha256=UMbBeIJ0afrrj3wQVRsdjSdcbOFsvMctkeAnU6lkqlg,3809
|
|
4
|
+
csrd/service/_exception_handler.py,sha256=ZlpDGxPj-A9f9OQVTQneDl8v5k3lKCi2IaGnHArMrMo,2145
|
|
5
|
+
csrd/service/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
csrd_service-0.1.0.dist-info/METADATA,sha256=pwnAeB5f4_GjmO2S1B04qll9AyOUfPxO3c8pbVSQsbY,466
|
|
7
|
+
csrd_service-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
csrd_service-0.1.0.dist-info/RECORD,,
|