dynamicforms-fastapi-viewsets 0.15.1__tar.gz → 0.15.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.
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/PKG-INFO +4 -3
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/README.md +3 -2
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/conf.py +13 -1
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/cursor.py +3 -1
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/transport.py +5 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/route_viewset.py +20 -4
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/viewset.py +15 -12
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/declaration.py +27 -5
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/list_query.py +31 -2
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/list_shapes.py +1 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/__init__.py +20 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/idempotency.py +16 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mixins.py +85 -19
- dynamicforms_fastapi_viewsets-0.15.2/fastapi_viewsets/response_classes.py +191 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml +1 -1
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml.orig +1 -1
- dynamicforms_fastapi_viewsets-0.15.1/fastapi_viewsets/response_classes.py +0 -113
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/query_model.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/sub_viewsets.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/error_namespace.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/error_responses.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/delivery.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/redis.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/scopes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/exceptions.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/cookies.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/routing.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/serialize_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.15.
|
|
3
|
+
Version: 0.15.2
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
5
|
Keywords: fastapi,viewsets,rest,dynamicforms,celery,django-rest-framework
|
|
6
6
|
Author: Jure Erznožnik
|
|
@@ -130,8 +130,9 @@ list pipeline and pagination, the muxws transport, events, and the Vue client.
|
|
|
130
130
|
## Demo
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
|
-
|
|
134
|
-
python demo.py
|
|
133
|
+
uv sync && npm ci
|
|
134
|
+
uv run python demo.py # backend on :8000, frontend on :5173
|
|
135
|
+
uv run python demo.py --celery # ... with every viewset call routed through a Celery worker
|
|
135
136
|
npm run test:e2e # drives the demo in a browser
|
|
136
137
|
```
|
|
137
138
|
|
|
@@ -91,8 +91,9 @@ list pipeline and pagination, the muxws transport, events, and the Vue client.
|
|
|
91
91
|
## Demo
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
|
|
95
|
-
python demo.py
|
|
94
|
+
uv sync && npm ci
|
|
95
|
+
uv run python demo.py # backend on :8000, frontend on :5173
|
|
96
|
+
uv run python demo.py --celery # ... with every viewset call routed through a Celery worker
|
|
96
97
|
npm run test:e2e # drives the demo in a browser
|
|
97
98
|
```
|
|
98
99
|
|
|
@@ -21,9 +21,16 @@ class Settings:
|
|
|
21
21
|
|
|
22
22
|
def __init__(self):
|
|
23
23
|
self.viewsets_context_processors: list[ContextProcessor] = []
|
|
24
|
+
"""Builds each request's context, in order."""
|
|
24
25
|
self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
|
|
26
|
+
"""Encodes context values that cross to a Celery worker; None is the standard encoder."""
|
|
25
27
|
self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
|
|
28
|
+
"""Decodes context values on the Celery worker; None is the standard decoder."""
|
|
26
29
|
self.viewsets_command_middleware: list[CommandMiddleware] = []
|
|
30
|
+
"""
|
|
31
|
+
Wraps every request-facing execution. The list is read on every call; the `modifies_response_shape`
|
|
32
|
+
and `openapi_parameters` of its entries when a viewset is decorated.
|
|
33
|
+
"""
|
|
27
34
|
self.viewsets_idempotency_ttl: float = 86400
|
|
28
35
|
"""Seconds an `Idempotency` outcome is kept; a later repeat runs the action again."""
|
|
29
36
|
self.viewsets_idempotency_lock_ttl: float = 600
|
|
@@ -32,7 +39,12 @@ class Settings:
|
|
|
32
39
|
it); once expired, a repeat runs the action again.
|
|
33
40
|
"""
|
|
34
41
|
self.viewsets_auth_processors: list[AuthBackend] = []
|
|
42
|
+
"""
|
|
43
|
+
Tried in order by `auth_context_processor` to resolve `context.user`; the first backend that claims
|
|
44
|
+
the request decides.
|
|
45
|
+
"""
|
|
35
46
|
self.default_action_configuration: dict[Any, Any] = {}
|
|
47
|
+
"""The configuration every action starts from."""
|
|
36
48
|
self.default_list_shape: str = "plain"
|
|
37
49
|
"""List shape of a viewset that sets none: "plain", "paginated" or "cursor"."""
|
|
38
50
|
self.viewsets_register_muxws: bool = True
|
|
@@ -43,7 +55,7 @@ class Settings:
|
|
|
43
55
|
self.viewsets_security_scheme: SecurityBase | None = None
|
|
44
56
|
"""
|
|
45
57
|
Security scheme added to every route for the OpenAPI "Authorize" flow only. Read at decoration
|
|
46
|
-
time
|
|
58
|
+
time.
|
|
47
59
|
"""
|
|
48
60
|
self.viewsets_event_store: EventStore | None = None
|
|
49
61
|
"""
|
|
@@ -20,6 +20,7 @@ from typing_extensions import TypeVar
|
|
|
20
20
|
|
|
21
21
|
from .error_namespace import _namespaced
|
|
22
22
|
from .filters import Filter
|
|
23
|
+
from .list_query import _with_description
|
|
23
24
|
|
|
24
25
|
if TYPE_CHECKING:
|
|
25
26
|
from .mixins import SortState
|
|
@@ -157,7 +158,8 @@ class CursorPage(BaseModel, Generic[TItem]):
|
|
|
157
158
|
are the same anchors read inclusively, present whenever the page is non-empty, for polling an edge.
|
|
158
159
|
"""
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
# A parametrised model (`CursorPage[Track]`) has no docstring of its own; a subclass keeps its own.
|
|
162
|
+
model_config = ConfigDict(use_attribute_docstrings=True, json_schema_extra=_with_description(__doc__))
|
|
161
163
|
|
|
162
164
|
results: list[TItem]
|
|
163
165
|
"""The records of the page."""
|
|
@@ -328,6 +328,7 @@ def encode_value(hint: Any, value: Any, what: str = "the value") -> tuple[Any, b
|
|
|
328
328
|
|
|
329
329
|
|
|
330
330
|
def decode_value(adapter: TypeAdapter | None, data: Any, typed: bool) -> Any:
|
|
331
|
+
"""`data` validated by `adapter` when `typed`, else rebuilt with `from_jsonable`; inverse of `encode_value`."""
|
|
331
332
|
if typed and adapter is not None:
|
|
332
333
|
return _decode_typed(adapter, data)
|
|
333
334
|
return from_jsonable(data)
|
|
@@ -354,6 +355,8 @@ def is_model_hint(hint: Any) -> bool:
|
|
|
354
355
|
|
|
355
356
|
@dataclass(frozen=True)
|
|
356
357
|
class ParamCodec:
|
|
358
|
+
"""How one parameter of an action is encoded for the worker."""
|
|
359
|
+
|
|
357
360
|
name: str
|
|
358
361
|
hint: Any
|
|
359
362
|
kind: str
|
|
@@ -424,6 +427,7 @@ class ActionCodec:
|
|
|
424
427
|
raise TypeError(f"{self.task_name}: {error}") from error
|
|
425
428
|
|
|
426
429
|
def decode_argument(self, name: str, data: Any, typed: bool) -> Any:
|
|
430
|
+
"""The value of parameter `name` from its encoded form; inverse of `encode_argument`."""
|
|
427
431
|
codec = self.params.get(name)
|
|
428
432
|
return decode_value(adapter_for(codec.hint) if codec is not None else None, data, typed)
|
|
429
433
|
|
|
@@ -435,6 +439,7 @@ class ActionCodec:
|
|
|
435
439
|
raise TypeError(f"{self.task_name}: {error}") from error
|
|
436
440
|
|
|
437
441
|
def decode_result(self, result: EncodedResult) -> Any:
|
|
442
|
+
"""The result from its `EncodedResult`; inverse of `encode_result`."""
|
|
438
443
|
return _decode_result(result.data, result.encoding, self.result_hint)
|
|
439
444
|
|
|
440
445
|
|
|
@@ -25,13 +25,18 @@ from fastapi_viewsets.endpoint_docs import (
|
|
|
25
25
|
register_tag,
|
|
26
26
|
viewset_description,
|
|
27
27
|
)
|
|
28
|
-
from fastapi_viewsets.middleware import
|
|
28
|
+
from fastapi_viewsets.middleware import (
|
|
29
|
+
any_modifies_response_shape,
|
|
30
|
+
Middleware,
|
|
31
|
+
middleware_openapi_parameters,
|
|
32
|
+
unwrap_viewset_result_type,
|
|
33
|
+
)
|
|
29
34
|
from fastapi_viewsets.mixins import (
|
|
30
35
|
FilterParam,
|
|
31
36
|
T as ITEM_TYPE_VAR, # noqa: N811
|
|
32
37
|
)
|
|
33
38
|
from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
|
|
34
|
-
from fastapi_viewsets.response_classes import with_error_responses
|
|
39
|
+
from fastapi_viewsets.response_classes import CURSOR_ERROR_RESPONSES, with_error_responses
|
|
35
40
|
from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
|
|
36
41
|
|
|
37
42
|
from .build_schema import _pydantic_generic_args, build_schema, route_to_add_api_route_kwargs
|
|
@@ -121,7 +126,8 @@ def resolve_typevars(type_map, annotation):
|
|
|
121
126
|
def _make_middleware_depends(cls: type, action_name: str):
|
|
122
127
|
"""
|
|
123
128
|
A per-route FastAPI dependency running `Middleware.depends()` before the body is parsed.
|
|
124
|
-
`@action_configuration
|
|
129
|
+
`@action_configuration`, the context processors and the command middleware are resolved per request,
|
|
130
|
+
`viewsets_security_scheme` when the route is built.
|
|
125
131
|
"""
|
|
126
132
|
|
|
127
133
|
async def _run(request: Request) -> None:
|
|
@@ -524,6 +530,7 @@ def _route(
|
|
|
524
530
|
),
|
|
525
531
|
)
|
|
526
532
|
|
|
533
|
+
middleware_parameters = middleware_openapi_parameters(settings.viewsets_command_middleware)
|
|
527
534
|
rest_routes = []
|
|
528
535
|
muxws_routes = []
|
|
529
536
|
documented = set()
|
|
@@ -538,11 +545,20 @@ def _route(
|
|
|
538
545
|
*gates,
|
|
539
546
|
Depends(_make_middleware_depends(cls, action_name)),
|
|
540
547
|
]
|
|
548
|
+
responses = route.responses
|
|
549
|
+
if getattr(route.endpoint, "__list_shape_aware__", False) and "cursor" not in cls.resolve_shapes()[1]:
|
|
550
|
+
responses = {
|
|
551
|
+
status: entry for status, entry in responses.items() if CURSOR_ERROR_RESPONSES.get(status) is not entry
|
|
552
|
+
}
|
|
541
553
|
route_kwargs = route_to_add_api_route_kwargs(
|
|
542
|
-
route, dependencies=dependencies, responses=with_error_responses(
|
|
554
|
+
route, dependencies=dependencies, responses=with_error_responses(responses)
|
|
543
555
|
)
|
|
544
556
|
# Per-viewset wording from @endpoint_docs.
|
|
545
557
|
route_kwargs.update(operation_docs_for(cls, action_name))
|
|
558
|
+
if middleware_parameters and not is_schema_route(route):
|
|
559
|
+
extra = dict(route_kwargs.get("openapi_extra") or {})
|
|
560
|
+
extra["parameters"] = [*extra.get("parameters", []), *middleware_parameters]
|
|
561
|
+
route_kwargs["openapi_extra"] = extra
|
|
546
562
|
if parent is not None and not route_kwargs.get("summary"):
|
|
547
563
|
# Prefix the mount, telling a sub-viewset's `List Items` apart from the parent's in its group.
|
|
548
564
|
default_summary = (route_kwargs.get("name") or action_name).replace("_", " ").title()
|
|
@@ -26,6 +26,7 @@ from ..decorators.route_viewset import route_viewset
|
|
|
26
26
|
from ..error_responses import ApiErrorBody, error_body
|
|
27
27
|
from ..exceptions import EventCursorsExceededError, EventTopicInvalidError
|
|
28
28
|
from ..mux_ws.transports import transports
|
|
29
|
+
from ..response_classes import POLL_ERROR_RESPONSES, SUBSCRIBE_ERROR_RESPONSES
|
|
29
30
|
from ..routing import PEER_SCOPE_KEY
|
|
30
31
|
from . import delivery
|
|
31
32
|
from .authorizers import authorize_topic
|
|
@@ -52,14 +53,14 @@ class TopicRequest(BaseModel):
|
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
class SubscribeAnswer(BaseModel):
|
|
55
|
-
"""The answer of `subscribe`. `scope` is
|
|
56
|
+
"""The answer of `subscribe`. `scope` is absent for an unscoped topic."""
|
|
56
57
|
|
|
57
58
|
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
58
59
|
|
|
59
60
|
rev: int
|
|
60
61
|
"""The topic's current rev, 0 for a topic without events."""
|
|
61
62
|
scope: str | None = None
|
|
62
|
-
"""The scope key of a scoped viewset topic;
|
|
63
|
+
"""The scope key of a scoped viewset topic; absent for an unscoped topic."""
|
|
63
64
|
|
|
64
65
|
@model_serializer(mode="wrap")
|
|
65
66
|
def _leave_out_no_scope(self, handler: SerializerFunctionWrapHandler, _info: SerializationInfo) -> Any:
|
|
@@ -72,7 +73,7 @@ class SubscribeAnswer(BaseModel):
|
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
class PollRequest(BaseModel):
|
|
75
|
-
"""The body of `poll`: each topic mapped to the rev the caller holds, or
|
|
76
|
+
"""The body of `poll`: each topic mapped to the rev the caller holds, or null to start from now."""
|
|
76
77
|
|
|
77
78
|
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
78
79
|
|
|
@@ -80,7 +81,7 @@ class PollRequest(BaseModel):
|
|
|
80
81
|
"""The rev the caller holds per topic; null to start from the topic's current rev."""
|
|
81
82
|
scopes: dict[str, str] | None = None
|
|
82
83
|
"""The scope key the caller holds per topic; a cursor whose scope key differs from the server's answers
|
|
83
|
-
`events: null`.
|
|
84
|
+
`events: null`. Absent or null trusts every cursor."""
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
class PolledEvent(BaseModel):
|
|
@@ -101,9 +102,9 @@ class PolledEvent(BaseModel):
|
|
|
101
102
|
|
|
102
103
|
class TopicAnswer(BaseModel):
|
|
103
104
|
"""
|
|
104
|
-
The answer of `poll` for one topic. `events` is
|
|
105
|
-
caller then re-reads its state. A refused or invalid topic has `rev` and `events`
|
|
106
|
-
and `status` set. `error`, `status` and `scope` are
|
|
105
|
+
The answer of `poll` for one topic. `events` is null when the log cannot answer the cursor; the
|
|
106
|
+
caller then re-reads its state. A refused or invalid topic has `rev` and `events` null and `error`
|
|
107
|
+
and `status` set. `error`, `status` and `scope` are absent when they have no value.
|
|
107
108
|
"""
|
|
108
109
|
|
|
109
110
|
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
@@ -117,7 +118,7 @@ class TopicAnswer(BaseModel):
|
|
|
117
118
|
status: int | None = None
|
|
118
119
|
"""The HTTP status of the refusal; set on every answer with `error`."""
|
|
119
120
|
scope: str | None = None
|
|
120
|
-
"""The scope key of a scoped viewset topic;
|
|
121
|
+
"""The scope key of a scoped viewset topic; absent for an unscoped topic."""
|
|
121
122
|
|
|
122
123
|
@model_serializer(mode="wrap")
|
|
123
124
|
def _leave_out_absent(self, handler: SerializerFunctionWrapHandler, _info: SerializationInfo) -> Any:
|
|
@@ -168,7 +169,7 @@ class EventsViewSet:
|
|
|
168
169
|
__router = APIRouter()
|
|
169
170
|
|
|
170
171
|
@transports(rest=False, muxws=True)
|
|
171
|
-
@__router.post("/subscribe")
|
|
172
|
+
@__router.post("/subscribe", responses=SUBSCRIBE_ERROR_RESPONSES)
|
|
172
173
|
async def subscribe(self, request: Request, context: Context, body: TopicRequest) -> SubscribeAnswer:
|
|
173
174
|
"""
|
|
174
175
|
Subscribes the socket to `topic` until it closes or unsubscribes, and answers the topic's current
|
|
@@ -176,7 +177,7 @@ class EventsViewSet:
|
|
|
176
177
|
|
|
177
178
|
Errors: 422 `fastapi-viewsets.event_topic_invalid` (malformed topic); 403
|
|
178
179
|
`fastapi-viewsets.event_topic_refused` or the authorizer's `HTTPException`; 403
|
|
179
|
-
`fastapi-viewsets.event_scope_missing` (scope
|
|
180
|
+
`fastapi-viewsets.event_scope_missing` (the topic has no scope key for the caller); 409
|
|
180
181
|
`fastapi-viewsets.event_subscriptions_exceeded` (`settings.viewsets_event_max_subscriptions`
|
|
181
182
|
reached). A 4xx refusal other than 429 also drops an existing subscription to the topic.
|
|
182
183
|
"""
|
|
@@ -205,10 +206,12 @@ class EventsViewSet:
|
|
|
205
206
|
await delivery.unsubscribe(_peer_of(request, "unsubscribe"), body.topic)
|
|
206
207
|
|
|
207
208
|
@transports(rest=True, muxws=True)
|
|
208
|
-
@__router.post(
|
|
209
|
+
@__router.post(
|
|
210
|
+
"/poll", responses=POLL_ERROR_RESPONSES, response_description="The answer of each topic of the body."
|
|
211
|
+
)
|
|
209
212
|
async def poll(self, request: Request, context: Context, body: PollRequest) -> PollAnswer:
|
|
210
213
|
"""
|
|
211
|
-
Answers every cursor: a
|
|
214
|
+
Answers every cursor: a null cursor gets the current rev and no events, any other the events
|
|
212
215
|
since it, or `events: null` when the log cannot answer it (or its `scopes` entry differs from
|
|
213
216
|
the server's scope key). Events refused by a delivery filter are left out; `prev` chains the
|
|
214
217
|
events returned and `rev` covers all of them.
|
|
@@ -22,6 +22,30 @@ _LIST_LOOKUPS = frozenset({"in", "overlaps"})
|
|
|
22
22
|
|
|
23
23
|
_LIST_DESCRIPTION = "One or more values; the parameter repeats for each (`?{parameter}=a&{parameter}=b`)."
|
|
24
24
|
|
|
25
|
+
_OPERATOR_DESCRIPTIONS = {
|
|
26
|
+
"exact": "Records whose `{field}` equals the value.",
|
|
27
|
+
"iexact": "Records whose `{field}` equals the value, case-insensitive.",
|
|
28
|
+
"contains": "Records whose `{field}` contains the value.",
|
|
29
|
+
"icontains": "Records whose `{field}` contains the value, case-insensitive.",
|
|
30
|
+
"startswith": "Records whose `{field}` starts with the value.",
|
|
31
|
+
"gt": "Records whose `{field}` is greater than the value.",
|
|
32
|
+
"gte": "Records whose `{field}` is greater than or equal to the value.",
|
|
33
|
+
"lt": "Records whose `{field}` is less than the value.",
|
|
34
|
+
"lte": "Records whose `{field}` is less than or equal to the value.",
|
|
35
|
+
"in": "Records whose `{field}` is one of the values.",
|
|
36
|
+
"isnull": "`true`: records whose `{field}` is null; `false`: records whose `{field}` is set.",
|
|
37
|
+
"overlaps": "Records whose `{field}` shares at least one member with the values.",
|
|
38
|
+
}
|
|
39
|
+
"""The description of a filter query parameter by operator; another operator gets a generic one."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parameter_description(field_name: str, lookup: str, parameter: str) -> str:
|
|
43
|
+
text = _OPERATOR_DESCRIPTIONS.get(lookup, "Records `{field}` matches with the `" + lookup + "` operator.")
|
|
44
|
+
text = text.format(field=field_name)
|
|
45
|
+
if lookup in _LIST_LOOKUPS:
|
|
46
|
+
text += " " + _LIST_DESCRIPTION.format(parameter=parameter)
|
|
47
|
+
return text + " Omit to not filter."
|
|
48
|
+
|
|
25
49
|
|
|
26
50
|
def _optional(annotation: Any) -> Any:
|
|
27
51
|
if get_origin(annotation) is Union and type(None) in get_args(annotation):
|
|
@@ -69,12 +93,10 @@ def make_filter_model(model: type[BaseModel], declaration: FilterDeclaration) ->
|
|
|
69
93
|
)
|
|
70
94
|
parameter = field_name if lookup == "exact" else f"{field_name}__{lookup}"
|
|
71
95
|
annotation = _parameter_annotation(field_annotation, lookup)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
else None
|
|
96
|
+
fields[parameter] = (
|
|
97
|
+
annotation,
|
|
98
|
+
Field(None, description=_parameter_description(field_name, lookup, parameter)),
|
|
76
99
|
)
|
|
77
|
-
fields[parameter] = (annotation, default)
|
|
78
100
|
spec[parameter] = (field_name, lookup, _item_annotation(field_annotation, lookup))
|
|
79
101
|
|
|
80
102
|
filter_model = create_model(f"{model.__name__}Filter", **fields)
|
|
@@ -4,6 +4,8 @@ The list request (`ListQuery`) and answer types. `perform_list` returns `ListRec
|
|
|
4
4
|
query down by overriding one of them. See docs/guide/list-pipeline.md.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import inspect
|
|
8
|
+
|
|
7
9
|
from collections.abc import AsyncIterable, AsyncIterator, Iterable
|
|
8
10
|
from dataclasses import dataclass, field
|
|
9
11
|
from typing import Any, Generic, TypeVar
|
|
@@ -16,6 +18,17 @@ Not the mixins' TypeVar: Pydantic returns the bare class for `M[X]` when X is M'
|
|
|
16
18
|
would strip the item type from `PaginatedList[T]`.
|
|
17
19
|
"""
|
|
18
20
|
|
|
21
|
+
|
|
22
|
+
def _with_description(doc: str | None):
|
|
23
|
+
"""A `json_schema_extra` that gives a schema without a description the cleaned `doc`."""
|
|
24
|
+
description = inspect.cleandoc(doc or "")
|
|
25
|
+
|
|
26
|
+
def extra(schema: dict[str, Any]) -> None:
|
|
27
|
+
schema.setdefault("description", description)
|
|
28
|
+
|
|
29
|
+
return extra
|
|
30
|
+
|
|
31
|
+
|
|
19
32
|
ListRecords = Iterable[TItem] | AsyncIterable[TItem]
|
|
20
33
|
"""Any sync or async iterable flowing through the list pipeline; `to_record` converts its items."""
|
|
21
34
|
|
|
@@ -28,11 +41,17 @@ class ListQuery:
|
|
|
28
41
|
"""
|
|
29
42
|
|
|
30
43
|
fltr: Any = None
|
|
44
|
+
"""The filter model instance; None when the request filters nothing."""
|
|
31
45
|
sort: list = field(default_factory=list)
|
|
46
|
+
"""The requested order (`SortState`); empty for no order."""
|
|
32
47
|
offset: int = 0
|
|
48
|
+
"""Number of records to skip."""
|
|
33
49
|
limit: int | None = None
|
|
50
|
+
"""Page size; None for the plain shape, which is not paged."""
|
|
34
51
|
applied: set[str] = field(default_factory=set)
|
|
52
|
+
"""Stages a backend performed in its store: `filter`, `sort`, `pagination`, `conversion`."""
|
|
35
53
|
cursor: str | None = None
|
|
54
|
+
"""The cursor the request sent, for the cursor shape."""
|
|
36
55
|
shape: str | None = None
|
|
37
56
|
"""The answer envelope; when unset, `get_list` uses the viewset's default shape."""
|
|
38
57
|
extra_filters: list = field(default_factory=list)
|
|
@@ -40,10 +59,12 @@ class ListQuery:
|
|
|
40
59
|
|
|
41
60
|
@property
|
|
42
61
|
def has_filter(self) -> bool:
|
|
62
|
+
"""Whether the request filters: a filter model or a pipeline filter."""
|
|
43
63
|
return self.fltr is not None or bool(self.extra_filters)
|
|
44
64
|
|
|
45
65
|
@property
|
|
46
66
|
def has_sort(self) -> bool:
|
|
67
|
+
"""Whether the request sets an order."""
|
|
47
68
|
return bool(self.sort)
|
|
48
69
|
|
|
49
70
|
@property
|
|
@@ -52,16 +73,24 @@ class ListQuery:
|
|
|
52
73
|
return self.limit is not None
|
|
53
74
|
|
|
54
75
|
def mark_applied(self, *stages: str) -> None:
|
|
76
|
+
"""Records `stages` as performed, so their in-memory steps are skipped."""
|
|
55
77
|
self.applied.update(stages)
|
|
56
78
|
|
|
57
79
|
def needs(self, stage: str) -> bool:
|
|
80
|
+
"""Whether the in-memory step of `stage` still has to run."""
|
|
58
81
|
return stage not in self.applied
|
|
59
82
|
|
|
60
83
|
|
|
84
|
+
_PAGINATED_LIST_DESCRIPTION = (
|
|
85
|
+
"One page of results; `count` is null when the source cannot be counted without draining it."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
61
89
|
class PaginatedList(BaseModel, Generic[TItem]):
|
|
62
|
-
"""One page of results; `count` is
|
|
90
|
+
"""One page of results; `count` is null when the source cannot be counted without draining it."""
|
|
63
91
|
|
|
64
|
-
|
|
92
|
+
# A parametrised model (`PaginatedList[Track]`) has no docstring of its own; a subclass keeps its own.
|
|
93
|
+
model_config = ConfigDict(use_attribute_docstrings=True, json_schema_extra=_with_description(__doc__))
|
|
65
94
|
|
|
66
95
|
results: list[TItem]
|
|
67
96
|
"""The records of the page."""
|
|
@@ -54,6 +54,7 @@ class ListOf(RootModel[list[TItem]], Generic[TItem]):
|
|
|
54
54
|
@classmethod
|
|
55
55
|
def __get_pydantic_json_schema__(cls, core_schema, handler):
|
|
56
56
|
schema = handler(core_schema)
|
|
57
|
+
schema.setdefault("description", "Every record of the list, as an array.")
|
|
57
58
|
if "example" not in schema:
|
|
58
59
|
item = cls.__pydantic_generic_metadata__.get("args", (None,))[0]
|
|
59
60
|
if item is not None and hasattr(item, "model_json_schema"):
|
|
@@ -185,6 +185,13 @@ class Middleware(ABC):
|
|
|
185
185
|
`route_viewset` reads it from `settings.viewsets_command_middleware` only, per class.
|
|
186
186
|
"""
|
|
187
187
|
|
|
188
|
+
openapi_parameters: tuple[dict[str, Any], ...] = ()
|
|
189
|
+
"""
|
|
190
|
+
OpenAPI parameter objects of the request values this middleware reads itself, such as a header.
|
|
191
|
+
`route_viewset` adds them to every operation of a viewset it routes, reading them from
|
|
192
|
+
`settings.viewsets_command_middleware` at that time. A function middleware sets it as an attribute.
|
|
193
|
+
"""
|
|
194
|
+
|
|
188
195
|
def config_from(self, context: Context) -> Any:
|
|
189
196
|
"""This middleware's `@action_configuration` value for the current call, or None."""
|
|
190
197
|
return context.configuration_for(type(self))
|
|
@@ -245,6 +252,19 @@ def any_modifies_response_shape(middlewares: list) -> bool:
|
|
|
245
252
|
return any(getattr(middleware, "modifies_response_shape", False) for middleware in middlewares)
|
|
246
253
|
|
|
247
254
|
|
|
255
|
+
def middleware_openapi_parameters(middlewares: list) -> list[dict[str, Any]]:
|
|
256
|
+
"""The `openapi_parameters` of `middlewares` in order, each name and location once."""
|
|
257
|
+
seen: set[tuple[str, str]] = set()
|
|
258
|
+
parameters: list[dict[str, Any]] = []
|
|
259
|
+
for middleware in middlewares:
|
|
260
|
+
for parameter in getattr(middleware, "openapi_parameters", ()):
|
|
261
|
+
identity = (parameter["name"].lower(), parameter["in"])
|
|
262
|
+
if identity not in seen:
|
|
263
|
+
seen.add(identity)
|
|
264
|
+
parameters.append(parameter)
|
|
265
|
+
return parameters
|
|
266
|
+
|
|
267
|
+
|
|
248
268
|
def unwrap_viewset_result_type(annotation: Any) -> Any:
|
|
249
269
|
"""`ViewSetResult[X]` to `X`, so it documents and validates like `-> X`; anything else unchanged."""
|
|
250
270
|
if get_origin(annotation) is ViewSetResult:
|
|
@@ -300,6 +300,22 @@ class Idempotency(Middleware):
|
|
|
300
300
|
process. The storage and replay rules are on the Idempotency guide page.
|
|
301
301
|
"""
|
|
302
302
|
|
|
303
|
+
openapi_parameters = (
|
|
304
|
+
{
|
|
305
|
+
"name": IDEMPOTENCY_KEY_HEADER,
|
|
306
|
+
"in": "header",
|
|
307
|
+
"required": False,
|
|
308
|
+
"description": (
|
|
309
|
+
"Makes the call repeatable: a repeat with the same key answers the first call's outcome. A "
|
|
310
|
+
"Structured Field string (RFC 8941), the key in double quotes. An invalid value answers 400 "
|
|
311
|
+
"`fastapi-viewsets.idempotency_key_invalid`; a key recorded for a different request answers "
|
|
312
|
+
"422 `fastapi-viewsets.idempotency_key_reused`."
|
|
313
|
+
),
|
|
314
|
+
"schema": {"type": "string"},
|
|
315
|
+
"example": '"8e03978e-40d5-43e8-bc93-6894a57f9324"',
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
303
319
|
def __init__(self, redis_client: "Redis | None" = None, key_prefix: str = "idempotency:"):
|
|
304
320
|
self.redis_client = redis_client
|
|
305
321
|
self.key_prefix = key_prefix
|
|
@@ -37,10 +37,18 @@ from fastapi_viewsets.list_query import (
|
|
|
37
37
|
take_page,
|
|
38
38
|
)
|
|
39
39
|
from fastapi_viewsets.list_shapes import resolve_shapes
|
|
40
|
-
from fastapi_viewsets.response_classes import NOT_FOUND_RESPONSE
|
|
40
|
+
from fastapi_viewsets.response_classes import BULK_NOT_FOUND_RESPONSE, CURSOR_ERROR_RESPONSES, NOT_FOUND_RESPONSE
|
|
41
41
|
|
|
42
42
|
T = TypeVar("T")
|
|
43
43
|
K = TypeVar("K")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _body_description(description: str) -> dict:
|
|
47
|
+
"""`openapi_extra` describing an endpoint's request body."""
|
|
48
|
+
return {"requestBody": {"description": description}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
BULK_UPDATE_BODY = _body_description('An object mapping each primary key to its record: `{"<pk>": {...}, ...}`.')
|
|
44
52
|
TFilter = TypeVar("TFilter", default=None)
|
|
45
53
|
|
|
46
54
|
|
|
@@ -134,8 +142,9 @@ class CreateMixin(Generic[K, T], ABC):
|
|
|
134
142
|
__router = APIRouter()
|
|
135
143
|
|
|
136
144
|
@final
|
|
137
|
-
@__router.post("")
|
|
145
|
+
@__router.post("", response_description="The created record.")
|
|
138
146
|
async def create(self: "ImplMixin[K, T] | CreateMixin[K ,T]", context: Context, data: T) -> T:
|
|
147
|
+
"""Create one record from the body. Answers the record as stored."""
|
|
139
148
|
return await self.perform_create(context, data)
|
|
140
149
|
|
|
141
150
|
|
|
@@ -147,10 +156,15 @@ class BulkOnlyCreateMixin(Generic[K, T], ABC):
|
|
|
147
156
|
__router = APIRouter()
|
|
148
157
|
|
|
149
158
|
@final
|
|
150
|
-
@__router.post(
|
|
159
|
+
@__router.post(
|
|
160
|
+
"bulk", response_description="The created records.", openapi_extra=_body_description("The records to create.")
|
|
161
|
+
)
|
|
151
162
|
async def bulk_create(
|
|
152
|
-
self: "ImplMixin[K, T] | BulkOnlyCreateMixin[K ,T]",
|
|
163
|
+
self: "ImplMixin[K, T] | BulkOnlyCreateMixin[K ,T]",
|
|
164
|
+
context: Context,
|
|
165
|
+
data: list[T],
|
|
153
166
|
) -> list[T]:
|
|
167
|
+
"""Create the records of the body, a list. Answers the records as stored."""
|
|
154
168
|
return await self.perform_bulk_create(context, data)
|
|
155
169
|
|
|
156
170
|
|
|
@@ -321,7 +335,11 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
321
335
|
return resolve_shapes(cls.list_shape, cls.list_shapes, settings.default_list_shape)
|
|
322
336
|
|
|
323
337
|
@final
|
|
324
|
-
@__router.get(
|
|
338
|
+
@__router.get(
|
|
339
|
+
"",
|
|
340
|
+
responses=CURSOR_ERROR_RESPONSES,
|
|
341
|
+
response_description="The records, in the list shape the request asked for.",
|
|
342
|
+
)
|
|
325
343
|
async def list_items(
|
|
326
344
|
self: "ImplMixin[Any, T] | ListMixin[T]",
|
|
327
345
|
context: Context,
|
|
@@ -542,7 +560,7 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
542
560
|
async def count_records(
|
|
543
561
|
self: "ImplMixin[Any, T] | ListMixin[T]",
|
|
544
562
|
context: Context, # noqa: ARG002 - part of the hook signature, for overrides to use
|
|
545
|
-
query: ListQuery,
|
|
563
|
+
query: ListQuery,
|
|
546
564
|
records: ListRecords,
|
|
547
565
|
) -> int | None:
|
|
548
566
|
"""
|
|
@@ -613,8 +631,9 @@ class RetrieveMixin(Generic[K, T], ABC):
|
|
|
613
631
|
__router = APIRouter()
|
|
614
632
|
|
|
615
633
|
@final
|
|
616
|
-
@__router.get("/{pk}", responses=NOT_FOUND_RESPONSE)
|
|
634
|
+
@__router.get("/{pk}", responses=NOT_FOUND_RESPONSE, response_description="The record.")
|
|
617
635
|
async def retrieve(self: "ImplMixin[K, T] | RetrieveMixin[K, T]", context: Context, pk: K) -> T:
|
|
636
|
+
"""Read one record. 404 `fastapi-viewsets.not_found` for a missing record."""
|
|
618
637
|
return await self.perform_retrieve(context, pk)
|
|
619
638
|
|
|
620
639
|
|
|
@@ -629,13 +648,23 @@ class UpdateMixin(Generic[K, T], ABC):
|
|
|
629
648
|
__router = APIRouter()
|
|
630
649
|
|
|
631
650
|
@final
|
|
632
|
-
@__router.put("/{pk}", responses=NOT_FOUND_RESPONSE)
|
|
651
|
+
@__router.put("/{pk}", responses=NOT_FOUND_RESPONSE, response_description="The updated record.")
|
|
633
652
|
async def update(self: "ImplMixin[K, T] | UpdateMixin[K, T]", context: Context, pk: K, data: T) -> T:
|
|
653
|
+
"""
|
|
654
|
+
Replace every field of one record with the body. Answers the record as stored; 404
|
|
655
|
+
`fastapi-viewsets.not_found` for a missing record.
|
|
656
|
+
"""
|
|
634
657
|
return await self.perform_update(context, pk, data, partial=False)
|
|
635
658
|
|
|
636
659
|
@final
|
|
637
|
-
@__router.patch(
|
|
660
|
+
@__router.patch(
|
|
661
|
+
"/{pk}", name="partial_update", responses=NOT_FOUND_RESPONSE, response_description="The updated record."
|
|
662
|
+
)
|
|
638
663
|
async def partial_update(self: "ImplMixin[K, T] | UpdateMixin[K, T]", context: Context, pk: K, data: T) -> T:
|
|
664
|
+
"""
|
|
665
|
+
Write the fields the body states to one record; the other fields keep their values. Answers the
|
|
666
|
+
record as stored; 404 `fastapi-viewsets.not_found` for a missing record.
|
|
667
|
+
"""
|
|
639
668
|
return await self.perform_update(context, pk, data, partial=True)
|
|
640
669
|
|
|
641
670
|
|
|
@@ -647,17 +676,40 @@ class BulkOnlyUpdateMixin(Generic[K, T], ABC):
|
|
|
647
676
|
__router = APIRouter()
|
|
648
677
|
|
|
649
678
|
@final
|
|
650
|
-
@__router.put(
|
|
679
|
+
@__router.put(
|
|
680
|
+
"bulk",
|
|
681
|
+
responses=BULK_NOT_FOUND_RESPONSE,
|
|
682
|
+
response_description="The updated records.",
|
|
683
|
+
openapi_extra=BULK_UPDATE_BODY,
|
|
684
|
+
)
|
|
651
685
|
async def bulk_update(
|
|
652
|
-
self: "ImplMixin[K, T] | BulkOnlyUpdateMixin[K, T]",
|
|
686
|
+
self: "ImplMixin[K, T] | BulkOnlyUpdateMixin[K, T]",
|
|
687
|
+
context: Context,
|
|
688
|
+
records: dict[K, T],
|
|
653
689
|
) -> list[T]:
|
|
690
|
+
"""
|
|
691
|
+
Replace every field of the records of the keys in the body. Answers the records as stored; 404
|
|
692
|
+
`fastapi-viewsets.not_found` when a key has no record.
|
|
693
|
+
"""
|
|
654
694
|
return await self.perform_bulk_update(context, records, partial=False)
|
|
655
695
|
|
|
656
696
|
@final
|
|
657
|
-
@__router.patch(
|
|
697
|
+
@__router.patch(
|
|
698
|
+
"bulk",
|
|
699
|
+
name="bulk_partial_update",
|
|
700
|
+
responses=BULK_NOT_FOUND_RESPONSE,
|
|
701
|
+
response_description="The updated records.",
|
|
702
|
+
openapi_extra=BULK_UPDATE_BODY,
|
|
703
|
+
)
|
|
658
704
|
async def bulk_partial_update(
|
|
659
|
-
self: "ImplMixin[K, T] | BulkOnlyUpdateMixin[K, T]",
|
|
705
|
+
self: "ImplMixin[K, T] | BulkOnlyUpdateMixin[K, T]",
|
|
706
|
+
context: Context,
|
|
707
|
+
records: dict[K, T],
|
|
660
708
|
) -> list[T]:
|
|
709
|
+
"""
|
|
710
|
+
Write the fields the body states to the records of its keys; the other fields keep their values.
|
|
711
|
+
Answers the records as stored; 404 `fastapi-viewsets.not_found` when a key has no record.
|
|
712
|
+
"""
|
|
661
713
|
return await self.perform_bulk_update(context, records, partial=True)
|
|
662
714
|
|
|
663
715
|
|
|
@@ -678,7 +730,7 @@ class DestroyMixin(Generic[K, T], ABC):
|
|
|
678
730
|
__router = APIRouter()
|
|
679
731
|
|
|
680
732
|
@final
|
|
681
|
-
@__router.delete("/{pk}", responses=NOT_FOUND_RESPONSE)
|
|
733
|
+
@__router.delete("/{pk}", responses=NOT_FOUND_RESPONSE, response_description="The primary key, mapped to null.")
|
|
682
734
|
async def destroy(self: "ImplMixin[K, T] | DestroyMixin[K, T]", context: Context, pk: K) -> dict[K, None]:
|
|
683
735
|
"""Destroy one record. Answers `{pk: null}`; 404 `fastapi-viewsets.not_found` for a missing record."""
|
|
684
736
|
return await self.perform_destroy(context, pk)
|
|
@@ -692,9 +744,15 @@ class BulkOnlyDestroyMixin(Generic[K, T], ABC):
|
|
|
692
744
|
__router = APIRouter()
|
|
693
745
|
|
|
694
746
|
@final
|
|
695
|
-
@__router.delete(
|
|
747
|
+
@__router.delete(
|
|
748
|
+
"bulk",
|
|
749
|
+
response_description="Each primary key, mapped to null when destroyed or to the error otherwise.",
|
|
750
|
+
openapi_extra=_body_description("The primary keys of the records to destroy."),
|
|
751
|
+
)
|
|
696
752
|
async def bulk_destroy(
|
|
697
|
-
self: "ImplMixin[K, T] | BulkOnlyDestroyMixin[K, T]",
|
|
753
|
+
self: "ImplMixin[K, T] | BulkOnlyDestroyMixin[K, T]",
|
|
754
|
+
context: Context,
|
|
755
|
+
pk: list[K],
|
|
698
756
|
) -> dict[K, ApiErrorBody | None]:
|
|
699
757
|
"""
|
|
700
758
|
Destroy the records of the keys in the body, each independently of the others. Answers 200 with an
|
|
@@ -716,8 +774,15 @@ class BulkDestroyMixin(DestroyMixin[K, T], BulkOnlyDestroyMixin[K, T]):
|
|
|
716
774
|
class LookupItem(BaseModel):
|
|
717
775
|
"""One choice of a lookup."""
|
|
718
776
|
|
|
719
|
-
group: Any = Field(
|
|
720
|
-
|
|
777
|
+
group: Any = Field(
|
|
778
|
+
None,
|
|
779
|
+
description="Group the choice is listed under; null for an ungrouped choice.",
|
|
780
|
+
json_schema_extra={"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]},
|
|
781
|
+
)
|
|
782
|
+
pk: object = Field(
|
|
783
|
+
description="Primary key of the record.",
|
|
784
|
+
json_schema_extra={"anyOf": [{"type": "integer"}, {"type": "string"}]},
|
|
785
|
+
)
|
|
721
786
|
title: str = Field(description="Text shown for the choice.")
|
|
722
787
|
icon: str | None = Field(None, description="Icon shown with the choice; null for none.")
|
|
723
788
|
|
|
@@ -800,12 +865,13 @@ class LookupMixin(Generic[TLookupFilter, TLookupItem], ABC):
|
|
|
800
865
|
return materialized
|
|
801
866
|
|
|
802
867
|
@final
|
|
803
|
-
@__router.get("lookup")
|
|
868
|
+
@__router.get("lookup", response_description="The choices.")
|
|
804
869
|
async def lookup(
|
|
805
870
|
self: "LookupMixin",
|
|
806
871
|
context: Context,
|
|
807
872
|
fltr: Annotated[TLookupFilter, Query()] = None,
|
|
808
873
|
) -> list[TLookupItem]:
|
|
874
|
+
"""Choices for a select or autocomplete input, filtered by the query parameters, in the source's order."""
|
|
809
875
|
return await self.get_lookup(context, build_list_query(fltr, None))
|
|
810
876
|
|
|
811
877
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The OpenAPI `responses` entries of the error responses of a viewset's endpoints. Every entry
|
|
3
|
+
documents the body as `ApiErrorBody` (`fastapi_viewsets.error_responses`).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .error_namespace import _namespaced
|
|
9
|
+
from .error_responses import ApiErrorBody, VALIDATION_FAILED_DETAIL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _example(detail: str, code: str, params: dict[str, Any] | None = None, fields: dict | None = None) -> dict:
|
|
13
|
+
return {"detail": detail, "detail_code": code, "detail_params": params or {}, "field_errors": fields or {}}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _entry(description: str, examples: dict[str, tuple[str, dict]]) -> dict[str, Any]:
|
|
17
|
+
return {
|
|
18
|
+
"model": ApiErrorBody,
|
|
19
|
+
"description": description,
|
|
20
|
+
"content": {
|
|
21
|
+
"application/json": {
|
|
22
|
+
"examples": {name: {"summary": summary, "value": value} for name, (summary, value) in examples.items()}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
NOT_FOUND_RESPONSE = {
|
|
29
|
+
"404": _entry(
|
|
30
|
+
"No item has the primary key `pk`: `fastapi-viewsets.not_found`.",
|
|
31
|
+
{
|
|
32
|
+
"not_found": (
|
|
33
|
+
"No item with this primary key",
|
|
34
|
+
_example("Item with pk 42 not found", _namespaced("not_found"), {"pk": 42}),
|
|
35
|
+
)
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
"""The `responses` entry of the endpoints that address one item by `pk`."""
|
|
40
|
+
|
|
41
|
+
BULK_NOT_FOUND_RESPONSE = {
|
|
42
|
+
"404": _entry(
|
|
43
|
+
"A primary key of the body has no item: `fastapi-viewsets.not_found`.",
|
|
44
|
+
{
|
|
45
|
+
"not_found": (
|
|
46
|
+
"No item with this primary key",
|
|
47
|
+
_example("Item with pk 42 not found", _namespaced("not_found"), {"pk": 42}),
|
|
48
|
+
)
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
"""The `responses` entry of the bulk endpoints that address items by the primary keys of the body."""
|
|
53
|
+
|
|
54
|
+
_VALIDATION_FAILED_EXAMPLE = (
|
|
55
|
+
"A value of the request failed validation",
|
|
56
|
+
_example(
|
|
57
|
+
VALIDATION_FAILED_DETAIL,
|
|
58
|
+
_namespaced("validation_failed"),
|
|
59
|
+
fields={
|
|
60
|
+
"name": [
|
|
61
|
+
{
|
|
62
|
+
"code": "pydantic.string_too_short",
|
|
63
|
+
"params": {"min_length": 3},
|
|
64
|
+
"detail": "String should have at least 3 characters",
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"": [{"code": "pydantic.missing", "params": {}, "detail": "Field required"}],
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_IDEMPOTENCY_KEY_REUSED_EXAMPLE = (
|
|
73
|
+
"With the Idempotency middleware: the Idempotency-Key was recorded for a different request",
|
|
74
|
+
_example(
|
|
75
|
+
"Idempotency key k1 was used for a different request", _namespaced("idempotency_key_reused"), {"key": "k1"}
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_422_DESCRIPTION = (
|
|
80
|
+
"The request failed validation: `fastapi-viewsets.validation_failed`, with each failure in `field_errors` "
|
|
81
|
+
"under the name of its field. With the `Idempotency` middleware, an `Idempotency-Key` recorded for a "
|
|
82
|
+
"different request: `fastapi-viewsets.idempotency_key_reused`."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_DEFAULT_DESCRIPTION = (
|
|
86
|
+
"Any other error, with the same body. The package's own errors carry their codes (see the error codes "
|
|
87
|
+
"guide): 400 `fastapi-viewsets.idempotency_key_invalid` (with the `Idempotency` middleware), 401 "
|
|
88
|
+
"`fastapi-viewsets.session_expired` (with the `Session` middleware), 403 `fastapi-viewsets.not_authorized` "
|
|
89
|
+
"(with the `Authorization` middleware), 405 `fastapi-viewsets.read_only` (a read-only collection), 429 "
|
|
90
|
+
"`fastapi-viewsets.rate_limited` (with the `RateLimiter` middleware), 503 and 504 of a deferred action. "
|
|
91
|
+
"An `HTTPException` of the application, or of a dependency, without a code of its own answers "
|
|
92
|
+
"`fastapi-viewsets.http_error` with its status in `detail_params.status`."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
_HTTP_ERROR_EXAMPLE = (
|
|
96
|
+
"An HTTPException without a code",
|
|
97
|
+
_example("Not your item", _namespaced("http_error"), {"status": 403}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
_RATE_LIMITED_EXAMPLE = (
|
|
101
|
+
"With the RateLimiter middleware: the call was refused",
|
|
102
|
+
_example("Rate limit exceeded", _namespaced("rate_limited")),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
VIEWSET_ERROR_RESPONSES = {
|
|
106
|
+
"422": _entry(
|
|
107
|
+
_422_DESCRIPTION,
|
|
108
|
+
{"validation_failed": _VALIDATION_FAILED_EXAMPLE, "idempotency_key_reused": _IDEMPOTENCY_KEY_REUSED_EXAMPLE},
|
|
109
|
+
),
|
|
110
|
+
"500": _entry(
|
|
111
|
+
"The endpoint raised an exception without a handler: `fastapi-viewsets.unhandled_error`. The body "
|
|
112
|
+
"names no part of the exception; the server logs it. A deferred action that failed in its executor "
|
|
113
|
+
"answers `fastapi-viewsets.action_failed`.",
|
|
114
|
+
{
|
|
115
|
+
"unhandled_error": (
|
|
116
|
+
"An exception without a handler",
|
|
117
|
+
_example("Internal Server Error", _namespaced("unhandled_error")),
|
|
118
|
+
)
|
|
119
|
+
},
|
|
120
|
+
),
|
|
121
|
+
"default": _entry(_DEFAULT_DESCRIPTION, {"http_error": _HTTP_ERROR_EXAMPLE, "rate_limited": _RATE_LIMITED_EXAMPLE}),
|
|
122
|
+
}
|
|
123
|
+
"""The `responses` entries every endpoint of a viewset carries, under the endpoint's own entries."""
|
|
124
|
+
|
|
125
|
+
CURSOR_ERROR_RESPONSES = {
|
|
126
|
+
"default": _entry(
|
|
127
|
+
_DEFAULT_DESCRIPTION + " A `cursor` the list cannot continue from answers 400: "
|
|
128
|
+
"`fastapi-viewsets.cursor_unreadable`, `fastapi-viewsets.cursor_missing_position`, "
|
|
129
|
+
"`fastapi-viewsets.cursor_stale`, `fastapi-viewsets.cursor_missing_keys`, "
|
|
130
|
+
"`fastapi-viewsets.cursor_value_mismatch`.",
|
|
131
|
+
{
|
|
132
|
+
"http_error": _HTTP_ERROR_EXAMPLE,
|
|
133
|
+
"cursor_stale": (
|
|
134
|
+
"The cursor belongs to another query",
|
|
135
|
+
_example(
|
|
136
|
+
"this cursor was issued for a different ordering or filter - start from the first page",
|
|
137
|
+
_namespaced("cursor_stale"),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
"rate_limited": _RATE_LIMITED_EXAMPLE,
|
|
141
|
+
},
|
|
142
|
+
),
|
|
143
|
+
}
|
|
144
|
+
"""The `responses` entry of the list endpoint; `route_viewset` keeps it on a viewset serving the cursor shape."""
|
|
145
|
+
|
|
146
|
+
SUBSCRIBE_ERROR_RESPONSES = {
|
|
147
|
+
"422": _entry(
|
|
148
|
+
_422_DESCRIPTION + " A malformed topic: `fastapi-viewsets.event_topic_invalid`.",
|
|
149
|
+
{
|
|
150
|
+
"validation_failed": _VALIDATION_FAILED_EXAMPLE,
|
|
151
|
+
"event_topic_invalid": (
|
|
152
|
+
"A malformed topic",
|
|
153
|
+
_example(
|
|
154
|
+
"Topic @x is neither a path starting with / nor a source name with an optional key",
|
|
155
|
+
_namespaced("event_topic_invalid"),
|
|
156
|
+
{"topic": "@x"},
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
"idempotency_key_reused": _IDEMPOTENCY_KEY_REUSED_EXAMPLE,
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
}
|
|
163
|
+
"""The `responses` entry of the events `subscribe` endpoint."""
|
|
164
|
+
|
|
165
|
+
POLL_ERROR_RESPONSES = {
|
|
166
|
+
"422": _entry(
|
|
167
|
+
_422_DESCRIPTION + " More cursors than `settings.viewsets_event_max_subscriptions`: "
|
|
168
|
+
"`fastapi-viewsets.event_cursors_exceeded`.",
|
|
169
|
+
{
|
|
170
|
+
"validation_failed": _VALIDATION_FAILED_EXAMPLE,
|
|
171
|
+
"event_cursors_exceeded": (
|
|
172
|
+
"More cursors than the server accepts in one poll",
|
|
173
|
+
_example(
|
|
174
|
+
"A poll carries at most 256 cursors, this one carries 300",
|
|
175
|
+
_namespaced("event_cursors_exceeded"),
|
|
176
|
+
{"count": 300, "limit": 256},
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
"idempotency_key_reused": _IDEMPOTENCY_KEY_REUSED_EXAMPLE,
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
"""The `responses` entry of the events `poll` endpoint."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def with_error_responses(responses: dict | None) -> dict:
|
|
187
|
+
"""`responses` over `VIEWSET_ERROR_RESPONSES`: an entry of `responses` replaces the entry of
|
|
188
|
+
the same status."""
|
|
189
|
+
merged = dict(VIEWSET_ERROR_RESPONSES)
|
|
190
|
+
merged.update({str(status): entry for status, entry in (responses or {}).items()})
|
|
191
|
+
return merged
|
{dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dynamicforms-fastapi-viewsets"
|
|
7
|
-
version = "0.15.
|
|
7
|
+
version = "0.15.2"
|
|
8
8
|
description = "Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
{dynamicforms_fastapi_viewsets-0.15.1 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml.orig
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dynamicforms-fastapi-viewsets"
|
|
7
|
-
version = "0.15.
|
|
7
|
+
version = "0.15.2"
|
|
8
8
|
description = "Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
The OpenAPI `responses` entries of the error responses of a viewset's endpoints. Every entry
|
|
3
|
-
documents the body as `ApiErrorBody` (`fastapi_viewsets.error_responses`).
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from .error_namespace import _namespaced
|
|
9
|
-
from .error_responses import ApiErrorBody, VALIDATION_FAILED_DETAIL
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _example(detail: str, code: str, params: dict[str, Any] | None = None, fields: dict | None = None) -> dict:
|
|
13
|
-
return {"detail": detail, "detail_code": code, "detail_params": params or {}, "field_errors": fields or {}}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _entry(description: str, examples: dict[str, tuple[str, dict]]) -> dict[str, Any]:
|
|
17
|
-
return {
|
|
18
|
-
"model": ApiErrorBody,
|
|
19
|
-
"description": description,
|
|
20
|
-
"content": {
|
|
21
|
-
"application/json": {
|
|
22
|
-
"examples": {name: {"summary": summary, "value": value} for name, (summary, value) in examples.items()}
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
NOT_FOUND_RESPONSE = {
|
|
29
|
-
"404": _entry(
|
|
30
|
-
"No item has the primary key `pk`: `fastapi-viewsets.not_found`.",
|
|
31
|
-
{
|
|
32
|
-
"not_found": (
|
|
33
|
-
"No item with this primary key",
|
|
34
|
-
_example("Item with pk 42 not found", _namespaced("not_found"), {"pk": 42}),
|
|
35
|
-
)
|
|
36
|
-
},
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
"""The `responses` entry of the endpoints that address one item by `pk`."""
|
|
40
|
-
|
|
41
|
-
VIEWSET_ERROR_RESPONSES = {
|
|
42
|
-
"422": _entry(
|
|
43
|
-
"The request failed validation: `fastapi-viewsets.validation_failed`, with each failure in "
|
|
44
|
-
"`field_errors` under the name of its field. A check the package makes of a valid request "
|
|
45
|
-
"answers 422 too: `fastapi-viewsets.idempotency_key_reused`, `fastapi-viewsets.unsupported_list_shape`, "
|
|
46
|
-
"`fastapi-viewsets.event_topic_invalid`, `fastapi-viewsets.event_cursors_exceeded`.",
|
|
47
|
-
{
|
|
48
|
-
"validation_failed": (
|
|
49
|
-
"A value of the request failed validation",
|
|
50
|
-
_example(
|
|
51
|
-
VALIDATION_FAILED_DETAIL,
|
|
52
|
-
_namespaced("validation_failed"),
|
|
53
|
-
fields={
|
|
54
|
-
"name": [
|
|
55
|
-
{
|
|
56
|
-
"code": "pydantic.string_too_short",
|
|
57
|
-
"params": {"min_length": 3},
|
|
58
|
-
"detail": "String should have at least 3 characters",
|
|
59
|
-
}
|
|
60
|
-
],
|
|
61
|
-
"": [{"code": "pydantic.missing", "params": {}, "detail": "Field required"}],
|
|
62
|
-
},
|
|
63
|
-
),
|
|
64
|
-
),
|
|
65
|
-
"idempotency_key_reused": (
|
|
66
|
-
"The Idempotency-Key was recorded for a different request",
|
|
67
|
-
_example(
|
|
68
|
-
"Idempotency key k1 was used for a different request",
|
|
69
|
-
_namespaced("idempotency_key_reused"),
|
|
70
|
-
{"key": "k1"},
|
|
71
|
-
),
|
|
72
|
-
),
|
|
73
|
-
},
|
|
74
|
-
),
|
|
75
|
-
"500": _entry(
|
|
76
|
-
"The endpoint raised an exception without a handler: `fastapi-viewsets.unhandled_error`. The body "
|
|
77
|
-
"names no part of the exception; the server logs it. A deferred action that failed in its executor "
|
|
78
|
-
"answers `fastapi-viewsets.action_failed`.",
|
|
79
|
-
{
|
|
80
|
-
"unhandled_error": (
|
|
81
|
-
"An exception without a handler",
|
|
82
|
-
_example("Internal Server Error", _namespaced("unhandled_error")),
|
|
83
|
-
)
|
|
84
|
-
},
|
|
85
|
-
),
|
|
86
|
-
"default": _entry(
|
|
87
|
-
"Any other error, with the same body. The package's own errors carry their codes (see the error "
|
|
88
|
-
"codes guide): 400 `fastapi-viewsets.idempotency_key_invalid` and the cursor codes, 401 "
|
|
89
|
-
"`fastapi-viewsets.session_expired`, 403 `fastapi-viewsets.not_authorized`, 405 "
|
|
90
|
-
"`fastapi-viewsets.read_only`, 429 `fastapi-viewsets.rate_limited`, 503 and 504 of a deferred "
|
|
91
|
-
"action. An `HTTPException` of the application, or of a dependency, without a code of its own "
|
|
92
|
-
"answers `fastapi-viewsets.http_error` with its status in `detail_params.status`.",
|
|
93
|
-
{
|
|
94
|
-
"http_error": (
|
|
95
|
-
"An HTTPException without a code",
|
|
96
|
-
_example("Not your item", _namespaced("http_error"), {"status": 403}),
|
|
97
|
-
),
|
|
98
|
-
"rate_limited": (
|
|
99
|
-
"Command middleware refused the call",
|
|
100
|
-
_example("Rate limit exceeded", _namespaced("rate_limited")),
|
|
101
|
-
),
|
|
102
|
-
},
|
|
103
|
-
),
|
|
104
|
-
}
|
|
105
|
-
"""The `responses` entries every endpoint of a viewset carries, under the endpoint's own entries."""
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def with_error_responses(responses: dict | None) -> dict:
|
|
109
|
-
"""`responses` over `VIEWSET_ERROR_RESPONSES`: an entry of `responses` replaces the entry of
|
|
110
|
-
the same status."""
|
|
111
|
-
merged = dict(VIEWSET_ERROR_RESPONSES)
|
|
112
|
-
merged.update({str(status): entry for status, entry in (responses or {}).items()})
|
|
113
|
-
return merged
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|