servicekit 2.0.0__tar.gz → 2.0.2__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.
- {servicekit-2.0.0 → servicekit-2.0.2}/PKG-INFO +5 -4
- {servicekit-2.0.0 → servicekit-2.0.2}/README.md +4 -3
- {servicekit-2.0.0 → servicekit-2.0.2}/pyproject.toml +1 -1
- {servicekit-2.0.0 → servicekit-2.0.2}/pyproject.toml.orig +1 -1
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/__init__.py +2 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/__init__.py +2 -1
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/crud.py +25 -5
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/middleware.py +10 -3
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/job.py +26 -14
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/service_builder.py +8 -4
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/exceptions.py +15 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/manager.py +6 -2
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/alembic/env.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/alembic/script.py.mako +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/app.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/apps/landing/index.html +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/apps/landing/manifest.json +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/auth.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/dependencies.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/monitoring.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/pagination.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/registration.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/router.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/__init__.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/health.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/metrics.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/system.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/sse.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/utilities.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/database.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/gunicorn.conf.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/logging.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/models.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/py.typed +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/repository.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/scheduler.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/schemas.py +0 -0
- {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: servicekit
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services
|
|
5
5
|
Keywords: fastapi,sqlalchemy,async,database,rest-api,crud,framework
|
|
6
6
|
Author: Morten Hansen
|
|
@@ -179,9 +179,10 @@ router = CrudRouter.create(
|
|
|
179
179
|
)
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
-
`POST` creates only and returns `409 Conflict` for an ID that already exists
|
|
183
|
-
omitted field keeps its value, an explicit `null` clears a nullable
|
|
184
|
-
and pagination is opt-in through `page` (>= 1) and `size` (1-100),
|
|
182
|
+
`POST` creates only and returns `409 Conflict` for an ID that already exists or for any other database constraint
|
|
183
|
+
violation. `PUT` applies the fields sent: an omitted field keeps its value, an explicit `null` clears a nullable
|
|
184
|
+
field. Collection listings are ordered by ID, and pagination is opt-in through `page` (>= 1) and `size` (1-100),
|
|
185
|
+
which return `422` when out of range.
|
|
185
186
|
|
|
186
187
|
|
|
187
188
|
## Examples
|
|
@@ -143,9 +143,10 @@ router = CrudRouter.create(
|
|
|
143
143
|
)
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
`POST` creates only and returns `409 Conflict` for an ID that already exists
|
|
147
|
-
omitted field keeps its value, an explicit `null` clears a nullable
|
|
148
|
-
and pagination is opt-in through `page` (>= 1) and `size` (1-100),
|
|
146
|
+
`POST` creates only and returns `409 Conflict` for an ID that already exists or for any other database constraint
|
|
147
|
+
violation. `PUT` applies the fields sent: an omitted field keeps its value, an explicit `null` clears a nullable
|
|
148
|
+
field. Collection listings are ordered by ID, and pagination is opt-in through `page` (>= 1) and `size` (1-100),
|
|
149
|
+
which return `422` when out of range.
|
|
149
150
|
|
|
150
151
|
|
|
151
152
|
## Examples
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "servicekit"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.2"
|
|
4
4
|
description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Morten Hansen", email = "morten@dhis2.org" }]
|
|
@@ -22,6 +22,7 @@ from .exceptions import (
|
|
|
22
22
|
ServicekitException,
|
|
23
23
|
UnauthorizedError,
|
|
24
24
|
ValidationError,
|
|
25
|
+
classify_integrity_error,
|
|
25
26
|
)
|
|
26
27
|
from .logging import add_request_context, clear_request_context, configure_logging, get_logger, reset_request_context
|
|
27
28
|
from .manager import BaseManager, LifecycleHooks, Manager
|
|
@@ -76,6 +77,7 @@ __all__ = [
|
|
|
76
77
|
"NotFoundError",
|
|
77
78
|
"ValidationError",
|
|
78
79
|
"ConflictError",
|
|
80
|
+
"classify_integrity_error",
|
|
79
81
|
"InvalidULIDError",
|
|
80
82
|
"BadRequestError",
|
|
81
83
|
"UnauthorizedError",
|
|
@@ -8,7 +8,7 @@ from .middleware import add_error_handlers, add_logging_middleware, database_err
|
|
|
8
8
|
from .pagination import PaginationParams, create_paginated_response
|
|
9
9
|
from .router import Router
|
|
10
10
|
from .routers import HealthRouter, HealthState, HealthStatus, JobRouter, MetricsRouter, SystemInfo, SystemRouter
|
|
11
|
-
from .service_builder import BaseServiceBuilder, ServiceInfo
|
|
11
|
+
from .service_builder import BaseServiceBuilder, JobOptions, ServiceInfo
|
|
12
12
|
from .sse import SSE_HEADERS, format_sse_event, format_sse_model_event
|
|
13
13
|
from .utilities import build_location_url, run_app
|
|
14
14
|
|
|
@@ -19,6 +19,7 @@ __all__ = [
|
|
|
19
19
|
"CrudPermissions",
|
|
20
20
|
# Service builder
|
|
21
21
|
"BaseServiceBuilder",
|
|
22
|
+
"JobOptions",
|
|
22
23
|
"ServiceInfo",
|
|
23
24
|
# App system
|
|
24
25
|
"App",
|
|
@@ -9,7 +9,22 @@ from ulid import ULID
|
|
|
9
9
|
|
|
10
10
|
from servicekit.api.router import Router
|
|
11
11
|
from servicekit.manager import Manager
|
|
12
|
-
from servicekit.schemas import PaginatedResponse
|
|
12
|
+
from servicekit.schemas import PaginatedResponse, ProblemDetail
|
|
13
|
+
|
|
14
|
+
# OpenAPI response documentation shared by CRUD routes
|
|
15
|
+
_CONFLICT_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
16
|
+
status.HTTP_409_CONFLICT: {
|
|
17
|
+
"model": ProblemDetail,
|
|
18
|
+
"description": "An entity with the supplied id already exists or a database constraint was violated",
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_NOT_FOUND_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
23
|
+
status.HTTP_404_NOT_FOUND: {
|
|
24
|
+
"model": ProblemDetail,
|
|
25
|
+
"description": "No entity exists with the supplied id",
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
# Type alias for manager factory function
|
|
15
30
|
type ManagerFactory[InSchemaT: BaseModel, OutSchemaT: BaseModel] = Callable[..., Manager[InSchemaT, OutSchemaT, ULID]]
|
|
@@ -171,7 +186,12 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
|
|
|
171
186
|
entity_out_annotation: Any = self.entity_out_type
|
|
172
187
|
router_prefix = self.router.prefix
|
|
173
188
|
|
|
174
|
-
@self.router.post(
|
|
189
|
+
@self.router.post(
|
|
190
|
+
"",
|
|
191
|
+
status_code=status.HTTP_201_CREATED,
|
|
192
|
+
response_model=entity_out_annotation,
|
|
193
|
+
responses=_CONFLICT_RESPONSES,
|
|
194
|
+
)
|
|
175
195
|
async def create(
|
|
176
196
|
entity_in: InSchemaT,
|
|
177
197
|
request: Request,
|
|
@@ -218,7 +238,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
|
|
|
218
238
|
entity_out_annotation: Any = self.entity_out_type
|
|
219
239
|
router_prefix = self.router.prefix
|
|
220
240
|
|
|
221
|
-
@self.router.get("/{entity_id}", response_model=entity_out_annotation)
|
|
241
|
+
@self.router.get("/{entity_id}", response_model=entity_out_annotation, responses=_NOT_FOUND_RESPONSES)
|
|
222
242
|
async def find_by_id(
|
|
223
243
|
entity_id: str,
|
|
224
244
|
manager: Manager[InSchemaT, OutSchemaT, ULID] = manager_dependency,
|
|
@@ -242,7 +262,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
|
|
|
242
262
|
entity_out_annotation: Any = self.entity_out_type
|
|
243
263
|
router_prefix = self.router.prefix
|
|
244
264
|
|
|
245
|
-
@self.router.put("/{entity_id}", response_model=entity_out_annotation)
|
|
265
|
+
@self.router.put("/{entity_id}", response_model=entity_out_annotation, responses=_NOT_FOUND_RESPONSES)
|
|
246
266
|
async def update(
|
|
247
267
|
entity_id: str,
|
|
248
268
|
entity_in: InSchemaT,
|
|
@@ -266,7 +286,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
|
|
|
266
286
|
def _register_delete_route(self, manager_dependency: Any, manager_annotation: Any) -> None:
|
|
267
287
|
router_prefix = self.router.prefix
|
|
268
288
|
|
|
269
|
-
@self.router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
289
|
+
@self.router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT, responses=_NOT_FOUND_RESPONSES)
|
|
270
290
|
async def delete_by_id(
|
|
271
291
|
entity_id: str,
|
|
272
292
|
manager: Manager[InSchemaT, OutSchemaT, ULID] = manager_dependency,
|
|
@@ -5,12 +5,13 @@ from typing import Any, Awaitable, Callable
|
|
|
5
5
|
|
|
6
6
|
from fastapi import Request, Response, status
|
|
7
7
|
from fastapi.encoders import jsonable_encoder
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
8
9
|
from fastapi.responses import JSONResponse
|
|
9
10
|
from pydantic import ValidationError as PydanticValidationError
|
|
10
11
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
12
|
from ulid import ULID
|
|
12
13
|
|
|
13
|
-
from servicekit.exceptions import ErrorType, ServicekitException
|
|
14
|
+
from servicekit.exceptions import ErrorType, ServicekitException, classify_integrity_error
|
|
14
15
|
from servicekit.logging import add_request_context, get_logger, reset_request_context
|
|
15
16
|
from servicekit.schemas import ProblemDetail
|
|
16
17
|
|
|
@@ -112,6 +113,8 @@ async def database_error_handler(request: Request, exc: Exception) -> JSONRespon
|
|
|
112
113
|
)
|
|
113
114
|
|
|
114
115
|
if isinstance(exc, IntegrityError):
|
|
116
|
+
constraint = classify_integrity_error(exc)
|
|
117
|
+
constraint_extension: dict[str, Any] = {"constraint": constraint} if constraint is not None else {}
|
|
115
118
|
problem = ProblemDetail(
|
|
116
119
|
type=ErrorType.CONFLICT,
|
|
117
120
|
title="Conflict",
|
|
@@ -119,6 +122,7 @@ async def database_error_handler(request: Request, exc: Exception) -> JSONRespon
|
|
|
119
122
|
detail="The request conflicts with existing data",
|
|
120
123
|
instance=str(request.url),
|
|
121
124
|
trace_id=trace_id,
|
|
125
|
+
**constraint_extension,
|
|
122
126
|
)
|
|
123
127
|
else:
|
|
124
128
|
problem = ProblemDetail(
|
|
@@ -138,9 +142,11 @@ async def database_error_handler(request: Request, exc: Exception) -> JSONRespon
|
|
|
138
142
|
|
|
139
143
|
|
|
140
144
|
async def validation_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
141
|
-
"""Handle validation errors
|
|
145
|
+
"""Handle Pydantic and FastAPI request validation errors as RFC 9457 Problem Details with structured errors."""
|
|
142
146
|
trace_id = str(ULID())
|
|
143
|
-
errors =
|
|
147
|
+
errors = (
|
|
148
|
+
jsonable_encoder(exc.errors()) if isinstance(exc, (PydanticValidationError, RequestValidationError)) else None
|
|
149
|
+
)
|
|
144
150
|
|
|
145
151
|
logger.warning(
|
|
146
152
|
"validation.error",
|
|
@@ -213,6 +219,7 @@ def add_error_handlers(app: Any) -> None:
|
|
|
213
219
|
app.add_exception_handler(SQLAlchemyError, database_error_handler)
|
|
214
220
|
app.add_exception_handler(IntegrityError, database_error_handler)
|
|
215
221
|
app.add_exception_handler(PydanticValidationError, validation_error_handler)
|
|
222
|
+
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
|
216
223
|
|
|
217
224
|
|
|
218
225
|
def add_logging_middleware(app: Any) -> None:
|
|
@@ -7,12 +7,13 @@ from collections.abc import AsyncGenerator, Callable
|
|
|
7
7
|
from typing import Annotated, Any
|
|
8
8
|
|
|
9
9
|
import ulid
|
|
10
|
-
from fastapi import Depends,
|
|
10
|
+
from fastapi import Depends, Query, status
|
|
11
11
|
from fastapi.responses import Response, StreamingResponse
|
|
12
12
|
from pydantic import TypeAdapter
|
|
13
13
|
|
|
14
14
|
from servicekit.api.router import Router
|
|
15
15
|
from servicekit.api.sse import SSE_HEADERS, format_sse_model_event
|
|
16
|
+
from servicekit.exceptions import InvalidULIDError, NotFoundError
|
|
16
17
|
from servicekit.scheduler import Scheduler
|
|
17
18
|
from servicekit.schemas import JobRecord, JobStatus
|
|
18
19
|
|
|
@@ -57,23 +58,23 @@ class JobRouter(Router):
|
|
|
57
58
|
job_id: str,
|
|
58
59
|
scheduler: Scheduler = scheduler_dependency,
|
|
59
60
|
) -> JobRecord:
|
|
61
|
+
ulid_id = self._parse_job_id(job_id)
|
|
60
62
|
try:
|
|
61
|
-
ulid_id = ULID.from_str(job_id)
|
|
62
63
|
return await scheduler.get_record(ulid_id)
|
|
63
|
-
except
|
|
64
|
-
raise
|
|
64
|
+
except KeyError as error:
|
|
65
|
+
raise self._job_not_found(job_id) from error
|
|
65
66
|
|
|
66
67
|
@self.router.delete("/{job_id}", summary="Cancel and delete job", status_code=status.HTTP_204_NO_CONTENT)
|
|
67
68
|
async def delete_job(
|
|
68
69
|
job_id: str,
|
|
69
70
|
scheduler: Scheduler = scheduler_dependency,
|
|
70
71
|
) -> Response:
|
|
72
|
+
ulid_id = self._parse_job_id(job_id)
|
|
71
73
|
try:
|
|
72
|
-
ulid_id = ULID.from_str(job_id)
|
|
73
74
|
await scheduler.delete(ulid_id)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
except KeyError as error:
|
|
76
|
+
raise self._job_not_found(job_id) from error
|
|
77
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
77
78
|
|
|
78
79
|
@self.router.get(
|
|
79
80
|
"/{job_id}/$stream",
|
|
@@ -87,16 +88,13 @@ class JobRouter(Router):
|
|
|
87
88
|
) -> StreamingResponse:
|
|
88
89
|
"""Stream real-time job status updates using Server-Sent Events."""
|
|
89
90
|
# Validate job_id format
|
|
90
|
-
|
|
91
|
-
ulid_id = ULID.from_str(job_id)
|
|
92
|
-
except ValueError:
|
|
93
|
-
raise HTTPException(status_code=400, detail="Invalid job ID format")
|
|
91
|
+
ulid_id = self._parse_job_id(job_id)
|
|
94
92
|
|
|
95
93
|
# Check job exists before starting stream
|
|
96
94
|
try:
|
|
97
95
|
await scheduler.get_record(ulid_id)
|
|
98
|
-
except KeyError:
|
|
99
|
-
raise
|
|
96
|
+
except KeyError as error:
|
|
97
|
+
raise self._job_not_found(job_id) from error
|
|
100
98
|
|
|
101
99
|
# SSE event generator
|
|
102
100
|
async def event_stream() -> AsyncGenerator[bytes, None]:
|
|
@@ -124,3 +122,17 @@ class JobRouter(Router):
|
|
|
124
122
|
media_type="text/event-stream",
|
|
125
123
|
headers=SSE_HEADERS,
|
|
126
124
|
)
|
|
125
|
+
|
|
126
|
+
def _parse_job_id(self, job_id: str) -> ULID:
|
|
127
|
+
"""Parse a job ID as a ULID, raising InvalidULIDError when malformed."""
|
|
128
|
+
try:
|
|
129
|
+
return ULID.from_str(job_id)
|
|
130
|
+
except ValueError as error:
|
|
131
|
+
raise InvalidULIDError(
|
|
132
|
+
f"Invalid job ID format: {job_id}",
|
|
133
|
+
instance=f"{self.router.prefix}/{job_id}",
|
|
134
|
+
) from error
|
|
135
|
+
|
|
136
|
+
def _job_not_found(self, job_id: str) -> NotFoundError:
|
|
137
|
+
"""Build the not-found error for a missing job."""
|
|
138
|
+
return NotFoundError(f"Job with id {job_id} not found", instance=f"{self.router.prefix}/{job_id}")
|
|
@@ -54,7 +54,7 @@ class _SystemOptions:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
@dataclass(frozen=True)
|
|
57
|
-
class
|
|
57
|
+
class JobOptions:
|
|
58
58
|
"""Configuration for job scheduler endpoints."""
|
|
59
59
|
|
|
60
60
|
prefix: str
|
|
@@ -63,6 +63,10 @@ class _JobOptions:
|
|
|
63
63
|
shutdown_timeout: float
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# Backwards-compatible alias for the previously private name
|
|
67
|
+
_JobOptions = JobOptions
|
|
68
|
+
|
|
69
|
+
|
|
66
70
|
@dataclass(frozen=True)
|
|
67
71
|
class _AuthOptions:
|
|
68
72
|
"""Configuration for API key authentication."""
|
|
@@ -153,7 +157,7 @@ class BaseServiceBuilder:
|
|
|
153
157
|
self._include_logging = include_logging
|
|
154
158
|
self._health_options: _HealthOptions | None = None
|
|
155
159
|
self._system_options: _SystemOptions | None = None
|
|
156
|
-
self._job_options:
|
|
160
|
+
self._job_options: JobOptions | None = None
|
|
157
161
|
self._auth_options: _AuthOptions | None = None
|
|
158
162
|
self._monitoring_options: _MonitoringOptions | None = None
|
|
159
163
|
self._registration_options: _RegistrationOptions | None = None
|
|
@@ -247,7 +251,7 @@ class BaseServiceBuilder:
|
|
|
247
251
|
shutdown_timeout: float = 10.0,
|
|
248
252
|
) -> Self:
|
|
249
253
|
"""Add job scheduler endpoints."""
|
|
250
|
-
self._job_options =
|
|
254
|
+
self._job_options = JobOptions(
|
|
251
255
|
prefix=prefix,
|
|
252
256
|
tags=list(tags) if tags is not None else ["Jobs"],
|
|
253
257
|
max_concurrency=max_concurrency,
|
|
@@ -776,7 +780,7 @@ class BaseServiceBuilder:
|
|
|
776
780
|
|
|
777
781
|
return lifespan
|
|
778
782
|
|
|
779
|
-
def _create_scheduler(self, job_options:
|
|
783
|
+
def _create_scheduler(self, job_options: JobOptions) -> Scheduler:
|
|
780
784
|
"""Create the scheduler used by the application (override in subclasses)."""
|
|
781
785
|
from servicekit.scheduler import InMemoryScheduler
|
|
782
786
|
|
|
@@ -137,3 +137,18 @@ class ForbiddenError(ServicekitException):
|
|
|
137
137
|
instance=instance,
|
|
138
138
|
**extensions,
|
|
139
139
|
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def classify_integrity_error(error: Exception) -> str | None:
|
|
143
|
+
"""Classify a database integrity error as a safe constraint name, or None when unrecognized."""
|
|
144
|
+
driver_error = getattr(error, "orig", None)
|
|
145
|
+
message = str(driver_error if driver_error is not None else error).upper()
|
|
146
|
+
if "FOREIGN KEY" in message:
|
|
147
|
+
return "foreign_key"
|
|
148
|
+
if "UNIQUE" in message:
|
|
149
|
+
return "unique"
|
|
150
|
+
if "NOT NULL" in message:
|
|
151
|
+
return "not_null"
|
|
152
|
+
if "CHECK" in message:
|
|
153
|
+
return "check"
|
|
154
|
+
return None
|
|
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Iterable, Sequence
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
from sqlalchemy.exc import IntegrityError
|
|
10
10
|
|
|
11
|
-
from servicekit.exceptions import ConflictError
|
|
11
|
+
from servicekit.exceptions import ConflictError, classify_integrity_error
|
|
12
12
|
from servicekit.repository import BaseRepository
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
@@ -154,7 +154,11 @@ class BaseManager[ModelT, InSchemaT: BaseModel, OutSchemaT: BaseModel, IdT](
|
|
|
154
154
|
await self.repo.commit()
|
|
155
155
|
except IntegrityError as e:
|
|
156
156
|
await self.repo.rollback()
|
|
157
|
-
|
|
157
|
+
if entity_id is not None and await self.repo.exists_by_id(entity_id):
|
|
158
|
+
raise ConflictError(f"Entity with id {entity_id} already exists") from e
|
|
159
|
+
constraint = classify_integrity_error(e)
|
|
160
|
+
extensions = {"constraint": constraint} if constraint is not None else {}
|
|
161
|
+
raise ConflictError("Entity violates a database constraint", **extensions) from e
|
|
158
162
|
await self.repo.refresh_many([entity])
|
|
159
163
|
await self.post_save(entity)
|
|
160
164
|
return self._to_output_schema(entity)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|