dynamicforms-fastapi-viewsets 0.11.3__tar.gz → 0.12.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.
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/PKG-INFO +3 -4
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/README.md +2 -3
- dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/__init__.py +5 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/collection_viewset.py +1 -2
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/conf.py +13 -5
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/cursor.py +10 -6
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/client.py +1 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +7 -4
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/server.py +12 -13
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/route_viewset.py +4 -1
- dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/error_namespace.py +57 -0
- dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/error_responses.py +261 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/__init__.py +2 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/conftest.py +8 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/delivery.py +19 -2
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/redis.py +117 -14
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/viewset.py +25 -22
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/exceptions.py +95 -67
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/__init__.py +8 -10
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/idempotency.py +3 -2
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/asgi.py +3 -6
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/protocol.py +21 -13
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/server.py +16 -2
- dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/response_classes.py +113 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/routing.py +38 -3
- dynamicforms_fastapi_viewsets-0.11.3/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.11.3/fastapi_viewsets/response_classes.py +0 -8
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/scopes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
5
|
Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
|
|
6
6
|
Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
|
|
@@ -72,8 +72,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
72
72
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
73
73
|
- **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
|
|
74
74
|
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
75
|
-
exception handlers, router dependencies and state - so both transports behave identically.
|
|
76
|
-
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
75
|
+
exception handlers, router dependencies and state - so both transports behave identically.
|
|
77
76
|
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
78
77
|
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
79
78
|
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
@@ -139,7 +138,7 @@ app.include_router(router)
|
|
|
139
138
|
|
|
140
139
|
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
141
140
|
the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
|
|
142
|
-
list pipeline and pagination, the muxws transport, and the Vue client.
|
|
141
|
+
list pipeline and pagination, the muxws transport, events, and the Vue client.
|
|
143
142
|
|
|
144
143
|
## Demo
|
|
145
144
|
|
|
@@ -18,8 +18,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
18
18
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
19
19
|
- **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
|
|
20
20
|
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
21
|
-
exception handlers, router dependencies and state - so both transports behave identically.
|
|
22
|
-
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
21
|
+
exception handlers, router dependencies and state - so both transports behave identically.
|
|
23
22
|
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
24
23
|
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
25
24
|
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
@@ -85,7 +84,7 @@ app.include_router(router)
|
|
|
85
84
|
|
|
86
85
|
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
87
86
|
the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
|
|
88
|
-
list pipeline and pagination, the muxws transport, and the Vue client.
|
|
87
|
+
list pipeline and pagination, the muxws transport, events, and the Vue client.
|
|
89
88
|
|
|
90
89
|
## Demo
|
|
91
90
|
|
|
@@ -211,8 +211,7 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
211
211
|
|
|
212
212
|
for field_name, field_info in data.__class__.model_fields.items():
|
|
213
213
|
if field_info.json_schema_extra and field_info.json_schema_extra.get("autoinc_int"):
|
|
214
|
-
#
|
|
215
|
-
# However, we usually want autoinc only if the value is not yet set
|
|
214
|
+
# a truthy value is kept; 0 or None gets the next value after the container's maximum
|
|
216
215
|
current_val = getattr(data, field_name, None)
|
|
217
216
|
if current_val:
|
|
218
217
|
continue
|
|
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
|
|
|
9
9
|
|
|
10
10
|
from .context.auth import AuthBackend
|
|
11
11
|
from .events.authorizers import EventAuthorizer
|
|
12
|
-
from .events.delivery import DeliveryFilter
|
|
12
|
+
from .events.delivery import DeliveryFilter, EventListener
|
|
13
13
|
from .events.store import EventStore
|
|
14
14
|
from .middleware import CommandMiddleware
|
|
15
15
|
|
|
@@ -40,8 +40,9 @@ class Settings:
|
|
|
40
40
|
"""
|
|
41
41
|
self.viewsets_idempotency_lock_ttl: float = 600
|
|
42
42
|
"""
|
|
43
|
-
Seconds a running record of `Idempotency` is kept after it was
|
|
44
|
-
|
|
43
|
+
Seconds a running record of `Idempotency` is kept after it was last extended; the process
|
|
44
|
+
running the call extends it every third of this time. Once it expires, a repeat runs the
|
|
45
|
+
action again.
|
|
45
46
|
"""
|
|
46
47
|
self.viewsets_auth_processors: list[AuthBackend] = []
|
|
47
48
|
self.default_action_configuration: dict[Any, Any] = {}
|
|
@@ -82,8 +83,15 @@ class Settings:
|
|
|
82
83
|
"""
|
|
83
84
|
self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
|
|
84
85
|
"""
|
|
85
|
-
The functions that decide, per subscription and per event, whether a push is sent
|
|
86
|
-
|
|
86
|
+
The functions that decide, per subscription and per event, whether a push is sent, and per
|
|
87
|
+
poll and per event whether the poll answers the event. A filter that answers False skips that
|
|
88
|
+
socket or leaves the event out of the poll answer; a filter that raises counts as True.
|
|
89
|
+
"""
|
|
90
|
+
self.viewsets_event_listeners: list[EventListener] = []
|
|
91
|
+
"""
|
|
92
|
+
The functions called with every event in every process whose store is started, before its
|
|
93
|
+
pushes, whether or not the process holds a subscription of its topic. A listener that raises is
|
|
94
|
+
logged and the next one runs.
|
|
87
95
|
"""
|
|
88
96
|
self.viewsets_event_log_size: int = 32
|
|
89
97
|
"""
|
|
@@ -34,6 +34,7 @@ from typing import Any, ClassVar, Generic, TYPE_CHECKING
|
|
|
34
34
|
from pydantic import BaseModel, TypeAdapter
|
|
35
35
|
from typing_extensions import TypeVar
|
|
36
36
|
|
|
37
|
+
from .error_namespace import _namespaced
|
|
37
38
|
from .filters import Filter
|
|
38
39
|
|
|
39
40
|
if TYPE_CHECKING:
|
|
@@ -43,7 +44,10 @@ TItem = TypeVar("TItem")
|
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
class CursorError(ValueError):
|
|
46
|
-
"""
|
|
47
|
+
"""
|
|
48
|
+
The cursor is unreadable, or does not belong to the query it was sent with. `code` is one of the
|
|
49
|
+
cursor codes, `fastapi-viewsets.cursor_<reason>`.
|
|
50
|
+
"""
|
|
47
51
|
|
|
48
52
|
def __init__(self, message: str, code: str, params: dict[str, Any] | None = None):
|
|
49
53
|
super().__init__(message)
|
|
@@ -135,16 +139,16 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
|
|
|
135
139
|
payload = json.loads(base64.urlsafe_b64decode(padded))
|
|
136
140
|
except (ValueError, binascii.Error) as error:
|
|
137
141
|
raise CursorError(
|
|
138
|
-
f"cursor is not readable: {error}", code="cursor_unreadable", params={"error": str(error)}
|
|
142
|
+
f"cursor is not readable: {error}", code=_namespaced("cursor_unreadable"), params={"error": str(error)}
|
|
139
143
|
) from None
|
|
140
144
|
|
|
141
145
|
if not isinstance(payload, dict) or not isinstance(payload.get("p"), dict):
|
|
142
|
-
raise CursorError("cursor is not readable: no position in it", code="cursor_missing_position")
|
|
146
|
+
raise CursorError("cursor is not readable: no position in it", code=_namespaced("cursor_missing_position"))
|
|
143
147
|
|
|
144
148
|
if payload.get("q") != expected_query:
|
|
145
149
|
raise CursorError(
|
|
146
150
|
"this cursor was issued for a different ordering or filter - start from the first page",
|
|
147
|
-
code="cursor_stale",
|
|
151
|
+
code=_namespaced("cursor_stale"),
|
|
148
152
|
)
|
|
149
153
|
|
|
150
154
|
position = payload["p"]
|
|
@@ -152,7 +156,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
|
|
|
152
156
|
if missing:
|
|
153
157
|
raise CursorError(
|
|
154
158
|
f"cursor has no value for ordering key(s): {', '.join(missing)}",
|
|
155
|
-
code="cursor_missing_keys",
|
|
159
|
+
code=_namespaced("cursor_missing_keys"),
|
|
156
160
|
params={"missing": missing},
|
|
157
161
|
)
|
|
158
162
|
|
|
@@ -168,7 +172,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
|
|
|
168
172
|
except Exception as error:
|
|
169
173
|
raise CursorError(
|
|
170
174
|
f"cursor value for {name!r} does not fit the field: {error}",
|
|
171
|
-
code="cursor_value_mismatch",
|
|
175
|
+
code=_namespaced("cursor_value_mismatch"),
|
|
172
176
|
params={"name": name, "error": str(error)},
|
|
173
177
|
) from None
|
|
174
178
|
|
|
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
|
|
|
27
27
|
T = TypeVar("T")
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
30
|
+
# Seconds a `celery_viewset_client` call waits for its result when `result_timeout` is not given.
|
|
30
31
|
DEFAULT_RESULT_TIMEOUT = 3600.0
|
|
31
32
|
|
|
32
33
|
# queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
|
|
@@ -136,8 +136,9 @@ def _payload_error(data: dict) -> Exception:
|
|
|
136
136
|
def error_payload(correlation_id: str, task: str | None, error: BaseException) -> dict:
|
|
137
137
|
"""
|
|
138
138
|
The payload that fails one call with `error`: an `HTTPException` (a `DfViewSetError` with its
|
|
139
|
-
class),
|
|
140
|
-
|
|
139
|
+
class), a `RequestValidationError` as a 422 with its errors as `detail` and `class`
|
|
140
|
+
`"RequestValidationError"`, any other error as `ActionFailedError`. `http_status_code` and
|
|
141
|
+
`http_detail` repeat the status and detail for a reader that does not decode `error`.
|
|
141
142
|
"""
|
|
142
143
|
if not isinstance(error, (HTTPException, RequestValidationError)):
|
|
143
144
|
error = ActionFailedError(task)
|
|
@@ -168,8 +169,10 @@ def push_result(
|
|
|
168
169
|
"""
|
|
169
170
|
Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
|
|
170
171
|
the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
|
|
171
|
-
of a class `fastapi_viewsets.exceptions` defines keeps its class)
|
|
172
|
-
as `ActionFailedError`. `task` names the
|
|
172
|
+
of a class `fastapi_viewsets.exceptions` defines keeps its class), and so does a
|
|
173
|
+
`RequestValidationError`; any other error reaches it as `ActionFailedError`. `task` names the
|
|
174
|
+
call's task in that error. The FastAPI process answers each as `fastapi_viewsets.error_responses`
|
|
175
|
+
answers it.
|
|
173
176
|
"""
|
|
174
177
|
if error is not None:
|
|
175
178
|
data = error_payload(correlation_id, task, error)
|
|
@@ -20,7 +20,6 @@ from functools import wraps
|
|
|
20
20
|
from typing import Any, get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
|
|
21
21
|
|
|
22
22
|
from fastapi import HTTPException
|
|
23
|
-
from fastapi.encoders import jsonable_encoder
|
|
24
23
|
from fastapi.exceptions import RequestValidationError
|
|
25
24
|
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
26
25
|
|
|
@@ -63,11 +62,9 @@ def _unwrap_optional(hint):
|
|
|
63
62
|
return hint
|
|
64
63
|
|
|
65
64
|
|
|
66
|
-
#
|
|
67
|
-
# SerializableObject values
|
|
68
|
-
#
|
|
69
|
-
# ViewSetResult's own (de)serialization. Kept under this name since call sites/tests here already
|
|
70
|
-
# import it as `_to_jsonable`.
|
|
65
|
+
# `fastapi_viewsets.context.to_jsonable`: converts Pydantic models, dicts, lists/tuples and
|
|
66
|
+
# SerializableObject values (e.g. a ViewSetResult a worker-side action returned directly); also used
|
|
67
|
+
# by ViewSetResult's own (de)serialization.
|
|
71
68
|
_to_jsonable = to_jsonable
|
|
72
69
|
|
|
73
70
|
|
|
@@ -668,14 +665,13 @@ def celery_viewset_server(
|
|
|
668
665
|
_report("interrupted", task_name, call, lambda t: ActionInterruptedError(t, "time_limit"))
|
|
669
666
|
raise pool_timeout
|
|
670
667
|
error = run.translate(caught)
|
|
671
|
-
if isinstance(error, RequestValidationError):
|
|
672
|
-
# a request the worker found invalid (a filter value that will not convert) answers as
|
|
673
|
-
# FastAPI answers one: 422 with the errors as `detail`
|
|
674
|
-
error = HTTPException(status_code=422, detail=jsonable_encoder(error.errors()))
|
|
675
668
|
process_exit = isinstance(error, (KeyboardInterrupt, SystemExit))
|
|
676
669
|
if process_exit:
|
|
677
670
|
pushed = ActionInterruptedError(task_name, "terminated")
|
|
678
|
-
elif isinstance(error, HTTPException):
|
|
671
|
+
elif isinstance(error, (HTTPException, RequestValidationError)):
|
|
672
|
+
# a request the worker found invalid (a filter value that will not convert) is pushed as
|
|
673
|
+
# the `RequestValidationError` it is, and answered as a request the FastAPI process found
|
|
674
|
+
# invalid
|
|
679
675
|
pushed = error
|
|
680
676
|
else:
|
|
681
677
|
pushed = ActionFailedError(task_name)
|
|
@@ -688,12 +684,15 @@ def celery_viewset_server(
|
|
|
688
684
|
exc_info=caught,
|
|
689
685
|
)
|
|
690
686
|
else:
|
|
687
|
+
status, detail = (
|
|
688
|
+
(pushed.status_code, pushed.detail) if isinstance(pushed, HTTPException) else (422, pushed.errors())
|
|
689
|
+
)
|
|
691
690
|
logger.info(
|
|
692
691
|
"Celery task returned standard error: %s (correlation_id=%s): %d: %s",
|
|
693
692
|
task_name,
|
|
694
693
|
correlation_id,
|
|
695
|
-
|
|
696
|
-
|
|
694
|
+
status,
|
|
695
|
+
detail,
|
|
697
696
|
)
|
|
698
697
|
if process_exit and not _supervised():
|
|
699
698
|
_report("interrupted", task_name, call, lambda _t: pushed)
|
|
@@ -24,6 +24,7 @@ from fastapi_viewsets.endpoint_docs import (
|
|
|
24
24
|
from fastapi_viewsets.middleware import any_modifies_response_shape, Middleware, unwrap_viewset_result_type
|
|
25
25
|
from fastapi_viewsets.mixins import FilterParam
|
|
26
26
|
from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
|
|
27
|
+
from fastapi_viewsets.response_classes import with_error_responses
|
|
27
28
|
from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
|
|
28
29
|
|
|
29
30
|
from .build_schema import _pydantic_generic_args, build_schema, route_to_add_api_route_kwargs
|
|
@@ -418,7 +419,9 @@ def route_viewset(
|
|
|
418
419
|
documented.add(action_name)
|
|
419
420
|
paths.setdefault(action_name, route.path[len(base) :] if route.path.startswith(base) else route.path)
|
|
420
421
|
dependencies = list(route.dependencies or []) + [Depends(_make_middleware_depends(cls, action_name))]
|
|
421
|
-
route_kwargs = route_to_add_api_route_kwargs(
|
|
422
|
+
route_kwargs = route_to_add_api_route_kwargs(
|
|
423
|
+
route, dependencies=dependencies, responses=with_error_responses(route.responses)
|
|
424
|
+
)
|
|
422
425
|
# Per-viewset wording for an endpoint the mixin provided - the mixin's own docstring is
|
|
423
426
|
# the same sentence on every viewset in the application. See endpoint_docs.py.
|
|
424
427
|
route_kwargs.update(docs_for(cls, action_name))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The namespaces of the error codes this package's errors carry.
|
|
3
|
+
|
|
4
|
+
An error code is `<namespace>.<code>`: `code` is snake_case without `.`, `namespace` one or more
|
|
5
|
+
segments of `[a-z0-9]+(-[a-z0-9]+)*` joined by `.`, and a full code splits at its last `.`. The
|
|
6
|
+
codes this package declares are in `ERROR_NAMESPACE`. `ERROR_NAMESPACE_PYDANTIC` is the namespace
|
|
7
|
+
of pydantic's error types, which the field errors of a `fastapi-viewsets.validation_failed` body carry
|
|
8
|
+
as `pydantic.<type>`, and of `pydantic.custom_error`, the field error of a type pydantic does not
|
|
9
|
+
declare. A code the application gives an error is used as given.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
ERROR_NAMESPACE = "fastapi-viewsets"
|
|
13
|
+
"""The namespace of every error code this package declares, e.g. `fastapi-viewsets.not_found`."""
|
|
14
|
+
|
|
15
|
+
ERROR_NAMESPACE_PYDANTIC = "pydantic"
|
|
16
|
+
"""The namespace of pydantic's error types, e.g. `pydantic.string_too_short`."""
|
|
17
|
+
|
|
18
|
+
_PREFIX = f"{ERROR_NAMESPACE}."
|
|
19
|
+
|
|
20
|
+
_DECLARED_CODES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"not_found",
|
|
23
|
+
"read_only",
|
|
24
|
+
"session_expired",
|
|
25
|
+
"not_authorized",
|
|
26
|
+
"rate_limited",
|
|
27
|
+
"result_timeout",
|
|
28
|
+
"result_reader_stopped",
|
|
29
|
+
"action_dispatch_failed",
|
|
30
|
+
"action_interrupted",
|
|
31
|
+
"action_failed",
|
|
32
|
+
"idempotency_key_invalid",
|
|
33
|
+
"idempotency_key_reused",
|
|
34
|
+
"unsupported_list_shape",
|
|
35
|
+
"event_topic_invalid",
|
|
36
|
+
"event_topic_refused",
|
|
37
|
+
"event_scope_missing",
|
|
38
|
+
"event_subscriptions_exceeded",
|
|
39
|
+
"event_cursors_exceeded",
|
|
40
|
+
"cursor_unreadable",
|
|
41
|
+
"cursor_missing_position",
|
|
42
|
+
"cursor_stale",
|
|
43
|
+
"cursor_missing_keys",
|
|
44
|
+
"cursor_value_mismatch",
|
|
45
|
+
"validation_failed",
|
|
46
|
+
"http_error",
|
|
47
|
+
"unhandled_error",
|
|
48
|
+
"command_invalid",
|
|
49
|
+
"pk_assigned_by_server",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
"""Every code this package declares, without the namespace; each is built with `_namespaced()`."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _namespaced(code: str) -> str:
|
|
56
|
+
"""The full code of `code`, an error code this package declares."""
|
|
57
|
+
return f"{_PREFIX}{code}"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The body of every error response: `ApiErrorBody`.
|
|
3
|
+
|
|
4
|
+
{
|
|
5
|
+
"detail": "Item with pk 42 not found",
|
|
6
|
+
"detail_code": "fastapi-viewsets.not_found",
|
|
7
|
+
"detail_params": {"pk": 42},
|
|
8
|
+
"field_errors": {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
`error_body()` builds it from an exception:
|
|
12
|
+
|
|
13
|
+
- a `DfViewSetError`: its `detail`, `code` and `params`;
|
|
14
|
+
- a `RequestValidationError`: `fastapi-viewsets.validation_failed`, with its errors in
|
|
15
|
+
`field_errors`;
|
|
16
|
+
- any other `HTTPException` (FastAPI's, Starlette's, one an application raises, Starlette's own
|
|
17
|
+
404 and 405 of the router): `fastapi-viewsets.http_error` with `params.status`;
|
|
18
|
+
- any other exception: `fastapi-viewsets.unhandled_error` (`UnhandledError`), status 500, with no
|
|
19
|
+
part of the exception in the body.
|
|
20
|
+
|
|
21
|
+
The routes of a viewset answer every error with this body without any registration (see
|
|
22
|
+
`routing.TransportRoute`). `install_error_handlers(app)` answers the errors of the application's
|
|
23
|
+
other routes, and of a request no route matches, with it too.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from http import HTTPStatus
|
|
27
|
+
from typing import Any, get_args
|
|
28
|
+
|
|
29
|
+
from fastapi import FastAPI
|
|
30
|
+
from fastapi.encoders import jsonable_encoder
|
|
31
|
+
from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler
|
|
32
|
+
from fastapi.exceptions import RequestValidationError
|
|
33
|
+
from fastapi.utils import is_body_allowed_for_status_code
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
from pydantic_core.core_schema import ErrorType
|
|
36
|
+
from starlette.exceptions import HTTPException
|
|
37
|
+
from starlette.requests import Request
|
|
38
|
+
from starlette.responses import JSONResponse, Response
|
|
39
|
+
|
|
40
|
+
from .error_namespace import _namespaced, ERROR_NAMESPACE_PYDANTIC
|
|
41
|
+
from .exceptions import DfViewSetError, UnhandledError
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ApiFieldError(BaseModel):
|
|
45
|
+
"""One failure of one value of a request that failed validation."""
|
|
46
|
+
|
|
47
|
+
code: str = Field(
|
|
48
|
+
description=(
|
|
49
|
+
"pydantic's error type in the namespace `pydantic`, e.g. `pydantic.string_too_short`; "
|
|
50
|
+
"`pydantic.custom_error` for a type pydantic does not declare (a `PydanticCustomError` of the "
|
|
51
|
+
"application); `fastapi-viewsets.pk_assigned_by_server` for a primary key in a create's body."
|
|
52
|
+
),
|
|
53
|
+
examples=["pydantic.string_too_short"],
|
|
54
|
+
)
|
|
55
|
+
params: dict[str, Any] = Field(
|
|
56
|
+
description=(
|
|
57
|
+
"The values pydantic built `detail` from (pydantic's `ctx`); `{}` without any. For "
|
|
58
|
+
"`pydantic.custom_error`: `{type, ctx}`, the custom error's type and its `ctx`."
|
|
59
|
+
),
|
|
60
|
+
examples=[{"min_length": 3}, {"type": "app.slug_taken", "ctx": {"slug": "blue"}}],
|
|
61
|
+
)
|
|
62
|
+
detail: str = Field(
|
|
63
|
+
description="pydantic's English message (`msg`).",
|
|
64
|
+
examples=["String should have at least 3 characters"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ApiErrorBody(BaseModel):
|
|
69
|
+
"""The body of every error response."""
|
|
70
|
+
|
|
71
|
+
detail: str = Field(
|
|
72
|
+
description="English text of the error, interpolated from `detail_params`. Not translated.",
|
|
73
|
+
examples=["Item with pk 42 not found"],
|
|
74
|
+
)
|
|
75
|
+
detail_code: str = Field(
|
|
76
|
+
description=(
|
|
77
|
+
"The error's full code, `<namespace>.<code>`: `fastapi-viewsets.<code>` for the errors the "
|
|
78
|
+
"package raises, the application's own code as given for its own `DfViewSetError`."
|
|
79
|
+
),
|
|
80
|
+
examples=["fastapi-viewsets.not_found"],
|
|
81
|
+
)
|
|
82
|
+
detail_params: dict[str, Any] = Field(
|
|
83
|
+
description="The values `detail` was interpolated with, by name; `{}` without any.",
|
|
84
|
+
examples=[{"pk": 42}],
|
|
85
|
+
)
|
|
86
|
+
field_errors: dict[str, list[ApiFieldError]] = Field(
|
|
87
|
+
description=(
|
|
88
|
+
"The failures of a request that failed validation (`fastapi-viewsets.validation_failed`), keyed "
|
|
89
|
+
"by field name: pydantic's `loc` without its first element (`body`, `query`, `path`, `header`, "
|
|
90
|
+
"`cookie`), joined with `.`; a failure of the body as a whole is keyed `''`. `{}` for every "
|
|
91
|
+
"other error."
|
|
92
|
+
),
|
|
93
|
+
examples=[{}],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
VALIDATION_FAILED_DETAIL = "The request failed validation"
|
|
98
|
+
"""The `detail` of `fastapi-viewsets.validation_failed`."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _jsonable_ctx(ctx: dict[str, Any] | None) -> dict[str, Any]:
|
|
102
|
+
"""pydantic's `ctx` as JSON values: an exception (the `error` of `value_error`) as its message."""
|
|
103
|
+
return jsonable_encoder(
|
|
104
|
+
{name: str(value) if isinstance(value, BaseException) else value for name, value in (ctx or {}).items()}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
PYDANTIC_ERROR_TYPES = frozenset(get_args(ErrorType))
|
|
109
|
+
"""The error types pydantic declares: the values of `pydantic_core.core_schema.ErrorType`."""
|
|
110
|
+
|
|
111
|
+
CUSTOM_ERROR_CODE = f"{ERROR_NAMESPACE_PYDANTIC}.custom_error"
|
|
112
|
+
"""The code of a field error whose type pydantic does not declare."""
|
|
113
|
+
|
|
114
|
+
_PACKAGE_FIELD_ERROR_CODES = {"pk_assigned_by_server": _namespaced("pk_assigned_by_server")}
|
|
115
|
+
"""The codes of the `PydanticCustomError` types this package raises, by type."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _field_error(error: Any) -> dict[str, Any]:
|
|
119
|
+
error_type = error.get("type")
|
|
120
|
+
ctx = _jsonable_ctx(error.get("ctx"))
|
|
121
|
+
if error_type in PYDANTIC_ERROR_TYPES:
|
|
122
|
+
code, params = f"{ERROR_NAMESPACE_PYDANTIC}.{error_type}", ctx
|
|
123
|
+
elif error_type in _PACKAGE_FIELD_ERROR_CODES:
|
|
124
|
+
code, params = _PACKAGE_FIELD_ERROR_CODES[error_type], ctx
|
|
125
|
+
else:
|
|
126
|
+
code, params = CUSTOM_ERROR_CODE, {"type": error_type, "ctx": ctx}
|
|
127
|
+
return {"code": code, "params": params, "detail": str(error.get("msg", ""))}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def field_errors(errors: Any) -> dict[str, list[dict[str, Any]]]:
|
|
131
|
+
"""
|
|
132
|
+
pydantic's errors (`RequestValidationError.errors()`) as `ApiErrorBody.field_errors`: keyed by
|
|
133
|
+
`loc` without its first element, joined with `.` (`("body", "address", "city")` is
|
|
134
|
+
`address.city`, `("body",)` is `''`), each `{code, params, detail}` with `msg` as `detail`.
|
|
135
|
+
`input` is left out.
|
|
136
|
+
|
|
137
|
+
- A `type` pydantic declares (`PYDANTIC_ERROR_TYPES`) is `code` in the namespace
|
|
138
|
+
`ERROR_NAMESPACE_PYDANTIC` (`pydantic.string_too_short`), with `ctx` as `params`.
|
|
139
|
+
- `pk_assigned_by_server`, the type this package raises, is `fastapi-viewsets.pk_assigned_by_server`,
|
|
140
|
+
with `ctx` as `params`.
|
|
141
|
+
- Any other `type` (a `PydanticCustomError` of the application) is `pydantic.custom_error`, with
|
|
142
|
+
`params` `{"type": <type>, "ctx": <ctx>}`: `ctx` is nested, so none of its keys collides with
|
|
143
|
+
`type`.
|
|
144
|
+
"""
|
|
145
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
146
|
+
for error in errors or ():
|
|
147
|
+
loc = error.get("loc") or ()
|
|
148
|
+
name = ".".join(str(part) for part in list(loc)[1:])
|
|
149
|
+
grouped.setdefault(name, []).append(_field_error(error))
|
|
150
|
+
return grouped
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _body(detail: str, code: str, params: dict[str, Any], fields: dict | None = None) -> dict[str, Any]:
|
|
154
|
+
return {"detail": detail, "detail_code": code, "detail_params": params, "field_errors": fields or {}}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def error_status(error: BaseException) -> int:
|
|
158
|
+
"""The status `error` is answered with: an `HTTPException`'s own, 422 for a
|
|
159
|
+
`RequestValidationError`, 500 for any other exception."""
|
|
160
|
+
if isinstance(error, HTTPException):
|
|
161
|
+
return error.status_code
|
|
162
|
+
if isinstance(error, RequestValidationError):
|
|
163
|
+
return 422
|
|
164
|
+
return 500
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def error_body(error: BaseException) -> dict[str, Any]:
|
|
168
|
+
"""
|
|
169
|
+
`error` as an `ApiErrorBody`, in JSON form.
|
|
170
|
+
|
|
171
|
+
- `DfViewSetError`: `detail`, `code` as `detail_code`, `params` as `detail_params`.
|
|
172
|
+
- `RequestValidationError`: `fastapi-viewsets.validation_failed`, `detail_params` `{}`, its errors
|
|
173
|
+
in `field_errors` (see `field_errors()`).
|
|
174
|
+
- another `HTTPException`: `fastapi-viewsets.http_error`, `detail_params` `{"status": <status>}`.
|
|
175
|
+
A string `detail` is the body's `detail`; any other `detail` is `detail_params.detail` and the
|
|
176
|
+
body's `detail` is the status phrase (`Bad Request`).
|
|
177
|
+
- any other exception: the body of `UnhandledError`.
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(error, RequestValidationError):
|
|
180
|
+
return _body(VALIDATION_FAILED_DETAIL, _namespaced("validation_failed"), {}, field_errors(error.errors()))
|
|
181
|
+
if not isinstance(error, HTTPException):
|
|
182
|
+
error = UnhandledError()
|
|
183
|
+
if isinstance(error, DfViewSetError):
|
|
184
|
+
return _body(jsonable_encoder(error.detail), error.code, jsonable_encoder(error.params))
|
|
185
|
+
params: dict[str, Any] = {"status": error.status_code}
|
|
186
|
+
detail = error.detail
|
|
187
|
+
if not isinstance(detail, str):
|
|
188
|
+
params["detail"] = jsonable_encoder(detail)
|
|
189
|
+
try:
|
|
190
|
+
detail = HTTPStatus(error.status_code).phrase
|
|
191
|
+
except ValueError:
|
|
192
|
+
detail = f"HTTP {error.status_code}"
|
|
193
|
+
return _body(detail, _namespaced("http_error"), params)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def error_response(error: BaseException) -> Response:
|
|
197
|
+
"""
|
|
198
|
+
The response that answers `error`: `error_status(error)`, `error_body(error)` as JSON, and an
|
|
199
|
+
`HTTPException`'s `headers`. A status that allows no body (204, 304) is answered without one.
|
|
200
|
+
"""
|
|
201
|
+
status = error_status(error)
|
|
202
|
+
headers = getattr(error, "headers", None) if isinstance(error, HTTPException) else None
|
|
203
|
+
if not is_body_allowed_for_status_code(status):
|
|
204
|
+
return Response(status_code=status, headers=headers)
|
|
205
|
+
return JSONResponse(error_body(error), status_code=status, headers=headers)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _handle_error(_request: Request, error: Exception) -> Response:
|
|
209
|
+
"""The handler `install_error_handlers` registers, and the one a viewset route answers
|
|
210
|
+
`HTTPException` and `RequestValidationError` with. For `Exception` Starlette's
|
|
211
|
+
`ServerErrorMiddleware` re-raises the exception after the response, and the server logs it."""
|
|
212
|
+
return error_response(error)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Registers the handlers that answer the errors of every route of `app` with an `ApiErrorBody`
|
|
218
|
+
(see `error_body()`), for `DfViewSetError`, `HTTPException` (Starlette's, which FastAPI's
|
|
219
|
+
subclasses), `RequestValidationError` and `Exception`. Starlette's own 404 of a path no route
|
|
220
|
+
matches and 405 of a method a route does not serve are `fastapi-viewsets.http_error` with
|
|
221
|
+
`params.status` 404 and 405. An exception without a handler of its own is answered with 500
|
|
222
|
+
`fastapi-viewsets.unhandled_error` and re-raised by Starlette, so the server logs it.
|
|
223
|
+
|
|
224
|
+
A handler the application registers for one of these classes afterwards replaces this one; a
|
|
225
|
+
handler for a more specific class, or for a status, takes precedence as Starlette's lookup
|
|
226
|
+
gives it.
|
|
227
|
+
|
|
228
|
+
The routes of a viewset answer with an `ApiErrorBody` whether or not this is called.
|
|
229
|
+
"""
|
|
230
|
+
app.add_exception_handler(DfViewSetError, _handle_error)
|
|
231
|
+
app.add_exception_handler(HTTPException, _handle_error)
|
|
232
|
+
app.add_exception_handler(RequestValidationError, _handle_error)
|
|
233
|
+
app.add_exception_handler(Exception, _handle_error)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_FASTAPI_HANDLERS = (http_exception_handler, request_validation_exception_handler)
|
|
237
|
+
"""The handlers a FastAPI application registers by default."""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
EXCEPTION_HANDLERS_SCOPE_KEY = "starlette.exception_handlers"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def uniform_exception_handlers(scope: dict[str, Any]) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Replaces, for the request of `scope`, FastAPI's default handlers of `HTTPException` and
|
|
246
|
+
`RequestValidationError` with one that answers an `ApiErrorBody`. Handlers the application
|
|
247
|
+
registered for a class or a status are kept.
|
|
248
|
+
"""
|
|
249
|
+
exception_handlers, status_handlers = scope.get(EXCEPTION_HANDLERS_SCOPE_KEY) or ({}, {})
|
|
250
|
+
handlers = {
|
|
251
|
+
key: (_handle_error if handler in _FASTAPI_HANDLERS else handler) for key, handler in exception_handlers.items()
|
|
252
|
+
}
|
|
253
|
+
handlers.setdefault(HTTPException, _handle_error)
|
|
254
|
+
handlers.setdefault(RequestValidationError, _handle_error)
|
|
255
|
+
scope[EXCEPTION_HANDLERS_SCOPE_KEY] = (handlers, status_handlers)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def answers_unhandled_itself(app: Any) -> bool:
|
|
259
|
+
"""Whether `app` has a handler of its own, not `install_error_handlers`', for `Exception` or 500."""
|
|
260
|
+
handlers = getattr(app, "exception_handlers", None) or {}
|
|
261
|
+
return any(key in handlers and handlers[key] is not _handle_error for key in (Exception, 500))
|
|
@@ -32,6 +32,7 @@ from .delivery import (
|
|
|
32
32
|
DeliveryFilter,
|
|
33
33
|
drop_peer,
|
|
34
34
|
drop_subscriptions,
|
|
35
|
+
EventListener,
|
|
35
36
|
passes_filters,
|
|
36
37
|
reset_subscriptions,
|
|
37
38
|
subscribe,
|
|
@@ -80,6 +81,7 @@ __all__ = [
|
|
|
80
81
|
"ChangeEvents",
|
|
81
82
|
"DeclaredSources",
|
|
82
83
|
"DeliveryFilter",
|
|
84
|
+
"EventListener",
|
|
83
85
|
"Event",
|
|
84
86
|
"EventAuthorizer",
|
|
85
87
|
"EventScope",
|
|
@@ -9,6 +9,7 @@ from typing import Any
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
11
|
from ..conf import settings
|
|
12
|
+
from ..error_namespace import ERROR_NAMESPACE
|
|
12
13
|
from . import store as store_module
|
|
13
14
|
from .delivery import reset_subscriptions
|
|
14
15
|
from .redis import _APPEND_SCRIPT
|
|
@@ -19,6 +20,7 @@ EVENT_SETTINGS = (
|
|
|
19
20
|
"viewsets_event_store",
|
|
20
21
|
"viewsets_event_authorizers",
|
|
21
22
|
"viewsets_event_delivery_filters",
|
|
23
|
+
"viewsets_event_listeners",
|
|
22
24
|
"viewsets_event_log_size",
|
|
23
25
|
"viewsets_event_log_ttl",
|
|
24
26
|
"viewsets_event_push_timeout",
|
|
@@ -33,6 +35,7 @@ def _reset_events():
|
|
|
33
35
|
saved = {name: getattr(settings, name) for name in EVENT_SETTINGS}
|
|
34
36
|
settings.viewsets_event_authorizers = []
|
|
35
37
|
settings.viewsets_event_delivery_filters = []
|
|
38
|
+
settings.viewsets_event_listeners = []
|
|
36
39
|
reset_subscriptions()
|
|
37
40
|
reset_sources()
|
|
38
41
|
reset_event_scopes()
|
|
@@ -255,3 +258,8 @@ class FakePubSub:
|
|
|
255
258
|
async def aclose(self) -> None:
|
|
256
259
|
await self.unsubscribe(*list(self.subscribed))
|
|
257
260
|
self.closed = True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def envelope(detail: str, code: str, **params: Any) -> dict[str, Any]:
|
|
264
|
+
"""The `ApiErrorBody` of the package's error `code` (without the namespace)."""
|
|
265
|
+
return {"detail": detail, "detail_code": f"{ERROR_NAMESPACE}.{code}", "detail_params": params, "field_errors": {}}
|