dynamicforms-fastapi-viewsets 0.11.4__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.4 → dynamicforms_fastapi_viewsets-0.12.0}/PKG-INFO +1 -1
- dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/__init__.py +5 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/cursor.py +10 -6
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +7 -6
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/server.py +9 -8
- {dynamicforms_fastapi_viewsets-0.11.4 → 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.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/conftest.py +6 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/viewset.py +25 -22
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/exceptions.py +95 -67
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/idempotency.py +3 -2
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/protocol.py +15 -5
- {dynamicforms_fastapi_viewsets-0.11.4 → 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.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/routing.py +38 -3
- dynamicforms_fastapi_viewsets-0.11.4/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.11.4/fastapi_viewsets/response_classes.py +0 -8
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/conf.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/delivery.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/redis.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/scopes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.4 → 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
|
|
@@ -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
|
|
|
@@ -136,9 +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), a `RequestValidationError` as a 422 with its errors as `detail
|
|
140
|
-
`ActionFailedError`. `http_status_code` and
|
|
141
|
-
reader that does not decode `error`.
|
|
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`.
|
|
142
142
|
"""
|
|
143
143
|
if not isinstance(error, (HTTPException, RequestValidationError)):
|
|
144
144
|
error = ActionFailedError(task)
|
|
@@ -169,9 +169,10 @@ def push_result(
|
|
|
169
169
|
"""
|
|
170
170
|
Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
|
|
171
171
|
the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
|
|
172
|
-
of a class `fastapi_viewsets.exceptions` defines keeps its class)
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
175
176
|
"""
|
|
176
177
|
if error is not None:
|
|
177
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
|
|
|
@@ -666,14 +665,13 @@ def celery_viewset_server(
|
|
|
666
665
|
_report("interrupted", task_name, call, lambda t: ActionInterruptedError(t, "time_limit"))
|
|
667
666
|
raise pool_timeout
|
|
668
667
|
error = run.translate(caught)
|
|
669
|
-
if isinstance(error, RequestValidationError):
|
|
670
|
-
# a request the worker found invalid (a filter value that will not convert) answers as
|
|
671
|
-
# FastAPI answers one: 422 with the errors as `detail`
|
|
672
|
-
error = HTTPException(status_code=422, detail=jsonable_encoder(error.errors()))
|
|
673
668
|
process_exit = isinstance(error, (KeyboardInterrupt, SystemExit))
|
|
674
669
|
if process_exit:
|
|
675
670
|
pushed = ActionInterruptedError(task_name, "terminated")
|
|
676
|
-
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
|
|
677
675
|
pushed = error
|
|
678
676
|
else:
|
|
679
677
|
pushed = ActionFailedError(task_name)
|
|
@@ -686,12 +684,15 @@ def celery_viewset_server(
|
|
|
686
684
|
exc_info=caught,
|
|
687
685
|
)
|
|
688
686
|
else:
|
|
687
|
+
status, detail = (
|
|
688
|
+
(pushed.status_code, pushed.detail) if isinstance(pushed, HTTPException) else (422, pushed.errors())
|
|
689
|
+
)
|
|
689
690
|
logger.info(
|
|
690
691
|
"Celery task returned standard error: %s (correlation_id=%s): %d: %s",
|
|
691
692
|
task_name,
|
|
692
693
|
correlation_id,
|
|
693
|
-
|
|
694
|
-
|
|
694
|
+
status,
|
|
695
|
+
detail,
|
|
695
696
|
)
|
|
696
697
|
if process_exit and not _supervised():
|
|
697
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))
|
|
@@ -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
|
|
@@ -257,3 +258,8 @@ class FakePubSub:
|
|
|
257
258
|
async def aclose(self) -> None:
|
|
258
259
|
await self.unsubscribe(*list(self.subscribed))
|
|
259
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": {}}
|
|
@@ -23,6 +23,7 @@ from starlette.exceptions import HTTPException
|
|
|
23
23
|
from ..conf import settings
|
|
24
24
|
from ..context import Context
|
|
25
25
|
from ..decorators.route_viewset import route_viewset
|
|
26
|
+
from ..error_responses import ApiErrorBody, error_body
|
|
26
27
|
from ..exceptions import EventCursorsExceededError, EventTopicInvalidError
|
|
27
28
|
from ..mux_ws.transports import transports
|
|
28
29
|
from ..routing import PEER_SCOPE_KEY
|
|
@@ -91,13 +92,14 @@ class TopicAnswer(BaseModel):
|
|
|
91
92
|
"""
|
|
92
93
|
The answer of `poll` for one topic. `events` is None when the log cannot answer the cursor; the
|
|
93
94
|
caller then re-reads the state it displays. A topic that was refused, or that has neither form,
|
|
94
|
-
has `rev` and `events` None, `error` set to the
|
|
95
|
-
status. `error`, `status` and `scope` are left out
|
|
95
|
+
has `rev` and `events` None, `error` set to the `ApiErrorBody` of the refusal, as a call refused
|
|
96
|
+
with it is answered, and `status` to its HTTP status. `error`, `status` and `scope` are left out
|
|
97
|
+
of the serialized answer when they are None.
|
|
96
98
|
"""
|
|
97
99
|
|
|
98
100
|
rev: int | None
|
|
99
101
|
events: list[PolledEvent] | None
|
|
100
|
-
error:
|
|
102
|
+
error: ApiErrorBody | None = None
|
|
101
103
|
status: int | None = None
|
|
102
104
|
"""The HTTP status of the refusal; set on every answer with `error`."""
|
|
103
105
|
scope: str | None = None
|
|
@@ -146,11 +148,6 @@ def _reauthorizer(request: Request, topic: str) -> Callable[[], Awaitable[str]]:
|
|
|
146
148
|
return reauthorize
|
|
147
149
|
|
|
148
150
|
|
|
149
|
-
def _error_code(error: HTTPException) -> str:
|
|
150
|
-
code = getattr(error, "code", None)
|
|
151
|
-
return code if isinstance(code, str) and code else "event_topic_refused"
|
|
152
|
-
|
|
153
|
-
|
|
154
151
|
class EventsViewSet:
|
|
155
152
|
"""
|
|
156
153
|
Subscriptions to event topics over a muxws socket, and polls of event topics over either
|
|
@@ -166,11 +163,12 @@ class EventsViewSet:
|
|
|
166
163
|
Subscribes the socket the command arrived on to `topic` and answers the current rev of its
|
|
167
164
|
store topic, with the scope key for a scoped viewset topic (see
|
|
168
165
|
`fastapi_viewsets.events.scopes`). Every event published to the store topic after it is
|
|
169
|
-
pushed to the socket until the socket closes or unsubscribes. 422
|
|
170
|
-
a topic of neither form, 403
|
|
171
|
-
`
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
pushed to the socket until the socket closes or unsubscribes. 422
|
|
167
|
+
`fastapi-viewsets.event_topic_invalid` for a topic of neither form, 403
|
|
168
|
+
`fastapi-viewsets.event_topic_refused`, or the status of the authorizer's `HTTPException`, for
|
|
169
|
+
a topic the authorizers do not admit, 403 `fastapi-viewsets.event_scope_missing` for a scoped
|
|
170
|
+
topic whose resolver answers None, 409 `fastapi-viewsets.event_subscriptions_exceeded` for a
|
|
171
|
+
socket that already holds `settings.viewsets_event_max_subscriptions` topics. A subscribe refused
|
|
174
172
|
with a status from 400 to 499 other than 429 removes the socket's subscription to the topic,
|
|
175
173
|
if it holds one; any other error, a 429 or 5xx included, leaves it as it is. A command served by
|
|
176
174
|
`process_command` without `peer=` raises `RuntimeError`.
|
|
@@ -208,18 +206,20 @@ class EventsViewSet:
|
|
|
208
206
|
"""
|
|
209
207
|
Answers every cursor of the request. Per topic the authorizers run, then the store topic is
|
|
210
208
|
resolved (see `fastapi_viewsets.events.scopes`); a refused topic answers `error` with the
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
209
|
+
`ApiErrorBody` of the refusal (`fastapi-viewsets.event_topic_refused`,
|
|
210
|
+
`fastapi-viewsets.event_scope_missing`, or the body of the `HTTPException` an authorizer raised,
|
|
211
|
+
FastAPI's or Starlette's: its own code for a `DfViewSetError`, `fastapi-viewsets.http_error`
|
|
212
|
+
for any other) and `status` with its HTTP status, and the body of
|
|
213
|
+
`fastapi-viewsets.event_topic_invalid` with 422 for a topic of neither form, and the other
|
|
214
|
+
topics are unaffected. A poll over muxws that refuses a topic with a status from 400
|
|
215
|
+
to 499 other than 429 removes the socket's subscription to it;
|
|
216
216
|
a 429 or a status of 500 or above keeps it. A None cursor answers the topic's current rev
|
|
217
217
|
and no events, a cursor equal to it `[]`, and any other cursor the events since it, or
|
|
218
218
|
`events: null` when the log holds no event whose `prev` is the cursor. When the request
|
|
219
219
|
carries `scopes`, a cursor of a topic whose entry in `scopes` (absent for none) differs from
|
|
220
220
|
the scope key the server resolves answers the current rev and `events: null`. Every answer of
|
|
221
|
-
a scoped topic carries its `scope`. 422 `event_cursors_exceeded` for more
|
|
222
|
-
`settings.viewsets_event_max_subscriptions`.
|
|
221
|
+
a scoped topic carries its `scope`. 422 `fastapi-viewsets.event_cursors_exceeded` for more
|
|
222
|
+
cursors than `settings.viewsets_event_max_subscriptions`.
|
|
223
223
|
|
|
224
224
|
The delivery filters run on every event since the cursor, with a `Subscription` of the
|
|
225
225
|
topic whose `peer` is the socket the poll arrived on, None over REST, and whose `context` is
|
|
@@ -237,13 +237,16 @@ class EventsViewSet:
|
|
|
237
237
|
admitted: list[ResolvedTopic] = []
|
|
238
238
|
for topic in cursors:
|
|
239
239
|
if not is_valid_topic(topic):
|
|
240
|
-
|
|
240
|
+
invalid = EventTopicInvalidError(topic)
|
|
241
|
+
answers[topic] = TopicAnswer(
|
|
242
|
+
rev=None, events=None, error=error_body(invalid), status=invalid.status_code
|
|
243
|
+
)
|
|
241
244
|
continue
|
|
242
245
|
try:
|
|
243
246
|
await authorize_topic(request, context, topic)
|
|
244
247
|
resolved = await resolve_topic(request, topic)
|
|
245
248
|
except HTTPException as error:
|
|
246
|
-
answers[topic] = TopicAnswer(rev=None, events=None, error=
|
|
249
|
+
answers[topic] = TopicAnswer(rev=None, events=None, error=error_body(error), status=error.status_code)
|
|
247
250
|
if peer is not None and delivery._is_refusal(error):
|
|
248
251
|
await delivery.unsubscribe(peer, topic)
|
|
249
252
|
continue
|