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.
@@ -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",)
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any