dynamicforms-fastapi-viewsets 0.15.0__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.0 → dynamicforms_fastapi_viewsets-0.15.2}/PKG-INFO +4 -3
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/README.md +3 -2
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/conf.py +13 -1
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/cursor.py +13 -1
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/build_schema.py +4 -3
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/transport.py +5 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/route_viewset.py +105 -8
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/endpoint_docs.py +14 -1
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/changes.py +8 -1
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/viewset.py +35 -12
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/declaration.py +27 -5
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/list_query.py +39 -2
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/list_shapes.py +1 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/__init__.py +20 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/idempotency.py +16 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mixins.py +105 -28
- dynamicforms_fastapi_viewsets-0.15.2/fastapi_viewsets/response_classes.py +191 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/routing.py +25 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml +1 -1
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml.orig +1 -1
- dynamicforms_fastapi_viewsets-0.15.0/fastapi_viewsets/response_classes.py +0 -113
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/query_model.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/sub_viewsets.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/error_namespace.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/error_responses.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/delivery.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/redis.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/scopes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/exceptions.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/cookies.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.15.0 → 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
|
"""
|
|
@@ -15,11 +15,12 @@ import json
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from typing import Any, ClassVar, Generic, TYPE_CHECKING
|
|
17
17
|
|
|
18
|
-
from pydantic import BaseModel, TypeAdapter
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
|
19
19
|
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,14 +158,25 @@ 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
|
|
|
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__))
|
|
163
|
+
|
|
160
164
|
results: list[TItem]
|
|
165
|
+
"""The records of the page."""
|
|
161
166
|
limit: int
|
|
167
|
+
"""Page size the page was read with."""
|
|
162
168
|
has_more: bool = False
|
|
169
|
+
"""Whether records follow the page."""
|
|
163
170
|
has_previous: bool = False
|
|
171
|
+
"""Whether records precede the page."""
|
|
164
172
|
next: str | None = None
|
|
173
|
+
"""Cursor of the records after the page; null when there are none."""
|
|
165
174
|
previous: str | None = None
|
|
175
|
+
"""Cursor of the records before the page; null when there are none."""
|
|
166
176
|
first: str | None = None
|
|
177
|
+
"""Cursor of the page's first record onwards (that record included); null for an empty page."""
|
|
167
178
|
last: str | None = None
|
|
179
|
+
"""Cursor of the page's last record backwards (that record included); null for an empty page."""
|
|
168
180
|
|
|
169
181
|
|
|
170
182
|
@dataclass(frozen=True)
|
|
@@ -7,8 +7,9 @@ from fastapi import APIRouter, FastAPI
|
|
|
7
7
|
from fastapi.routing import APIRoute
|
|
8
8
|
from starlette.responses import Response
|
|
9
9
|
|
|
10
|
-
from ..endpoint_docs import
|
|
10
|
+
from ..endpoint_docs import operation_docs_for, viewset_tags
|
|
11
11
|
from ..middleware import unwrap_viewset_result_type
|
|
12
|
+
from ..routing import SCHEMA_ENDPOINT_DOCS
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def _generic_args(annotation) -> tuple:
|
|
@@ -144,7 +145,7 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
144
145
|
endpoint=schema,
|
|
145
146
|
tags=default_tags,
|
|
146
147
|
methods=["GET"],
|
|
147
|
-
|
|
148
|
+
**SCHEMA_ENDPOINT_DOCS,
|
|
148
149
|
)
|
|
149
150
|
class_router.add_api_route(**schema_route_kwargs)
|
|
150
151
|
|
|
@@ -179,7 +180,7 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
179
180
|
route.endpoint.__name__,
|
|
180
181
|
getattr(endpoint_wrapper, "__shape_openapi_extra__", None) or route.openapi_extra,
|
|
181
182
|
),
|
|
182
|
-
**
|
|
183
|
+
**operation_docs_for(cls, route.endpoint.__name__),
|
|
183
184
|
)
|
|
184
185
|
)
|
|
185
186
|
|
|
@@ -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
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import copy
|
|
1
2
|
import inspect
|
|
2
3
|
import re
|
|
3
4
|
|
|
@@ -5,8 +6,8 @@ from dataclasses import dataclass
|
|
|
5
6
|
from functools import wraps
|
|
6
7
|
from typing import Annotated, get_args, get_origin, Literal, TypeVar
|
|
7
8
|
|
|
8
|
-
from fastapi import APIRouter, Depends, Header, Request, Response
|
|
9
|
-
from fastapi.params import FieldInfo
|
|
9
|
+
from fastapi import APIRouter, Depends, Header, Path, Request, Response
|
|
10
|
+
from fastapi.params import FieldInfo, Header as HeaderParam, Param
|
|
10
11
|
from pydantic import BaseModel
|
|
11
12
|
|
|
12
13
|
try:
|
|
@@ -20,13 +21,22 @@ from fastapi_viewsets.context import build_context, Context, get_shared_context
|
|
|
20
21
|
from fastapi_viewsets.endpoint_docs import (
|
|
21
22
|
DOCS_ATTRIBUTE,
|
|
22
23
|
docs_for,
|
|
24
|
+
operation_docs_for,
|
|
23
25
|
register_tag,
|
|
24
26
|
viewset_description,
|
|
25
27
|
)
|
|
26
|
-
from fastapi_viewsets.middleware import
|
|
27
|
-
|
|
28
|
+
from fastapi_viewsets.middleware import (
|
|
29
|
+
any_modifies_response_shape,
|
|
30
|
+
Middleware,
|
|
31
|
+
middleware_openapi_parameters,
|
|
32
|
+
unwrap_viewset_result_type,
|
|
33
|
+
)
|
|
34
|
+
from fastapi_viewsets.mixins import (
|
|
35
|
+
FilterParam,
|
|
36
|
+
T as ITEM_TYPE_VAR, # noqa: N811
|
|
37
|
+
)
|
|
28
38
|
from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
|
|
29
|
-
from fastapi_viewsets.response_classes import with_error_responses
|
|
39
|
+
from fastapi_viewsets.response_classes import CURSOR_ERROR_RESPONSES, with_error_responses
|
|
30
40
|
from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
|
|
31
41
|
|
|
32
42
|
from .build_schema import _pydantic_generic_args, build_schema, route_to_add_api_route_kwargs
|
|
@@ -116,7 +126,8 @@ def resolve_typevars(type_map, annotation):
|
|
|
116
126
|
def _make_middleware_depends(cls: type, action_name: str):
|
|
117
127
|
"""
|
|
118
128
|
A per-route FastAPI dependency running `Middleware.depends()` before the body is parsed.
|
|
119
|
-
`@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.
|
|
120
131
|
"""
|
|
121
132
|
|
|
122
133
|
async def _run(request: Request) -> None:
|
|
@@ -172,6 +183,48 @@ def _with_security_scheme(run):
|
|
|
172
183
|
return dependency
|
|
173
184
|
|
|
174
185
|
|
|
186
|
+
PK_DESCRIPTION = "Primary key of the record."
|
|
187
|
+
"""The `pk` path parameter's description when the model's primary key field has none."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _pk_description(model, pk_field_name: str | None) -> str:
|
|
191
|
+
"""The description of the `pk` path parameter: that of the model's primary key field, else `PK_DESCRIPTION`."""
|
|
192
|
+
fields = getattr(model, "model_fields", None) or {}
|
|
193
|
+
field = fields.get(pk_field_name) if pk_field_name else None
|
|
194
|
+
return field.description if field is not None and field.description else PK_DESCRIPTION
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _published_name(name: str, annotation) -> str | None:
|
|
198
|
+
"""The schema name of a path, query, header or cookie parameter; None for any other parameter."""
|
|
199
|
+
for metadata in getattr(annotation, "__metadata__", ()):
|
|
200
|
+
if isinstance(metadata, Param):
|
|
201
|
+
if metadata.alias:
|
|
202
|
+
return metadata.alias
|
|
203
|
+
if isinstance(metadata, HeaderParam) and metadata.convert_underscores:
|
|
204
|
+
return name.replace("_", "-")
|
|
205
|
+
return name
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _with_description(annotation, description: str):
|
|
210
|
+
"""`annotation` (`Annotated[X, Param]`) with the parameter's description replaced."""
|
|
211
|
+
metadata = []
|
|
212
|
+
for item in annotation.__metadata__:
|
|
213
|
+
if isinstance(item, Param):
|
|
214
|
+
item = copy.copy(item)
|
|
215
|
+
item.description = description
|
|
216
|
+
item._attributes_set = {**getattr(item, "_attributes_set", {}), "description": description}
|
|
217
|
+
metadata.append(item)
|
|
218
|
+
return Annotated[(annotation.__origin__, *metadata)]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_absent_model_param(annotation) -> bool:
|
|
222
|
+
"""Whether `annotation` is `Annotated[None, Query()]`: a filter parameter of a viewset declaring no filter."""
|
|
223
|
+
args = getattr(annotation, "__args__", None)
|
|
224
|
+
metadata = getattr(annotation, "__metadata__", None)
|
|
225
|
+
return bool(args and metadata) and args[0] in (None, type(None)) and any(isinstance(m, FieldInfo) for m in metadata)
|
|
226
|
+
|
|
227
|
+
|
|
175
228
|
def route_viewset(
|
|
176
229
|
router: APIRouter,
|
|
177
230
|
base_path: str,
|
|
@@ -276,6 +329,14 @@ def _route(
|
|
|
276
329
|
),
|
|
277
330
|
]
|
|
278
331
|
)
|
|
332
|
+
if name == "limit":
|
|
333
|
+
parameter = parameter.replace(
|
|
334
|
+
annotation=_with_description(
|
|
335
|
+
parameter.annotation,
|
|
336
|
+
f"Page size. Omit for {cls.default_page_size}; a larger value than {cls.max_page_size}"
|
|
337
|
+
f" returns {cls.max_page_size}.",
|
|
338
|
+
)
|
|
339
|
+
)
|
|
279
340
|
if name == "sort" and cls.default_sort is not None:
|
|
280
341
|
check_declared_sort(cls.default_sort, item_type)
|
|
281
342
|
parameter = parameter.replace(default=cls.default_sort)
|
|
@@ -286,6 +347,26 @@ def _route(
|
|
|
286
347
|
return sig.replace(parameters=params, return_annotation=response_model(allowed, item_type))
|
|
287
348
|
|
|
288
349
|
shape_openapi_extra = None
|
|
350
|
+
item_model = type_map.get(ITEM_TYPE_VAR)
|
|
351
|
+
|
|
352
|
+
def _describe_parameters(params: list[inspect.Parameter], descriptions: dict[str, str]) -> None:
|
|
353
|
+
"""Replaces the descriptions of `params` named in `descriptions` (`@endpoint_docs` `parameters`)."""
|
|
354
|
+
if not descriptions:
|
|
355
|
+
return
|
|
356
|
+
published = {}
|
|
357
|
+
for index, parameter in enumerate(params):
|
|
358
|
+
name = _published_name(parameter.name, parameter.annotation)
|
|
359
|
+
if name is not None:
|
|
360
|
+
published[name] = index
|
|
361
|
+
unknown = set(descriptions) - set(published)
|
|
362
|
+
if unknown:
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"@endpoint_docs on {cls.__name__} describes parameter(s) its endpoint does not have: "
|
|
365
|
+
f"{', '.join(sorted(unknown))}; it has {', '.join(sorted(published)) or 'none'}"
|
|
366
|
+
)
|
|
367
|
+
for name, description in descriptions.items():
|
|
368
|
+
index = published[name]
|
|
369
|
+
params[index] = params[index].replace(annotation=_with_description(params[index].annotation, description))
|
|
289
370
|
|
|
290
371
|
def get_wrapper(original_endpoint, route_path, route_methods):
|
|
291
372
|
nonlocal shape_openapi_extra
|
|
@@ -303,6 +384,10 @@ def _route(
|
|
|
303
384
|
continue
|
|
304
385
|
|
|
305
386
|
annotation = resolve_typevars(type_map, p.annotation)
|
|
387
|
+
if _is_absent_model_param(annotation):
|
|
388
|
+
continue
|
|
389
|
+
if name == "pk" and "{pk}" in route_path and _published_name(name, annotation) is None:
|
|
390
|
+
annotation = Annotated[annotation, Path(description=_pk_description(item_model, pk_field_name))]
|
|
306
391
|
new_p = p.replace(annotation=annotation)
|
|
307
392
|
|
|
308
393
|
# `context` (detected by name) never reaches FastAPI; lifecycle_runner builds and injects it.
|
|
@@ -335,6 +420,8 @@ def _route(
|
|
|
335
420
|
|
|
336
421
|
new_params.append(new_p)
|
|
337
422
|
|
|
423
|
+
_describe_parameters(new_params, docs_for(cls, original_endpoint.__name__).get("parameters", {}))
|
|
424
|
+
|
|
338
425
|
# Reserved: FastAPI injects the real Response, onto which lifecycle_runner applies headers/cookies.
|
|
339
426
|
new_params.append(inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response))
|
|
340
427
|
|
|
@@ -443,6 +530,7 @@ def _route(
|
|
|
443
530
|
),
|
|
444
531
|
)
|
|
445
532
|
|
|
533
|
+
middleware_parameters = middleware_openapi_parameters(settings.viewsets_command_middleware)
|
|
446
534
|
rest_routes = []
|
|
447
535
|
muxws_routes = []
|
|
448
536
|
documented = set()
|
|
@@ -457,11 +545,20 @@ def _route(
|
|
|
457
545
|
*gates,
|
|
458
546
|
Depends(_make_middleware_depends(cls, action_name)),
|
|
459
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
|
+
}
|
|
460
553
|
route_kwargs = route_to_add_api_route_kwargs(
|
|
461
|
-
route, dependencies=dependencies, responses=with_error_responses(
|
|
554
|
+
route, dependencies=dependencies, responses=with_error_responses(responses)
|
|
462
555
|
)
|
|
463
556
|
# Per-viewset wording from @endpoint_docs.
|
|
464
|
-
route_kwargs.update(
|
|
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
|
|
465
562
|
if parent is not None and not route_kwargs.get("summary"):
|
|
466
563
|
# Prefix the mount, telling a sub-viewset's `List Items` apart from the parent's in its group.
|
|
467
564
|
default_summary = (route_kwargs.get("name") or action_name).replace("_", " ").title()
|
|
@@ -19,6 +19,8 @@ class EndpointDoc(TypedDict, total=False):
|
|
|
19
19
|
response_description: str
|
|
20
20
|
deprecated: bool
|
|
21
21
|
tags: list[str]
|
|
22
|
+
parameters: dict[str, str]
|
|
23
|
+
"""Descriptions of the endpoint's path, query and header parameters, by their name in the schema."""
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def endpoint_docs(docs: dict[str, EndpointDoc]):
|
|
@@ -40,15 +42,26 @@ def endpoint_docs(docs: dict[str, EndpointDoc]):
|
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
def docs_for(cls: type, action: str) -> dict[str, Any]:
|
|
43
|
-
"""
|
|
45
|
+
"""
|
|
46
|
+
The documentation of one action, merged along the MRO (subclass entries override; `parameters` merges
|
|
47
|
+
per parameter).
|
|
48
|
+
"""
|
|
44
49
|
merged: dict[str, Any] = {}
|
|
45
50
|
for base in reversed(cls.__mro__):
|
|
46
51
|
entry = base.__dict__.get(DOCS_ATTRIBUTE, {}).get(action)
|
|
47
52
|
if entry:
|
|
53
|
+
parameters = {**merged.get("parameters", {}), **entry.get("parameters", {})}
|
|
48
54
|
merged.update(entry)
|
|
55
|
+
if parameters:
|
|
56
|
+
merged["parameters"] = parameters
|
|
49
57
|
return merged
|
|
50
58
|
|
|
51
59
|
|
|
60
|
+
def operation_docs_for(cls: type, action: str) -> dict[str, Any]:
|
|
61
|
+
"""`docs_for` without `parameters`: the keyword arguments of `add_api_route`."""
|
|
62
|
+
return {key: value for key, value in docs_for(cls, action).items() if key != "parameters"}
|
|
63
|
+
|
|
64
|
+
|
|
52
65
|
# ---------------------------------------------------------------------------
|
|
53
66
|
# Viewset-level description
|
|
54
67
|
# ---------------------------------------------------------------------------
|
|
@@ -10,7 +10,7 @@ import logging
|
|
|
10
10
|
from collections.abc import Awaitable, Callable, Mapping
|
|
11
11
|
from typing import Any, Literal, TYPE_CHECKING
|
|
12
12
|
|
|
13
|
-
from pydantic import BaseModel
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
14
|
|
|
15
15
|
from ..action_configuration import resolve_action_configuration
|
|
16
16
|
from ..context import _Deferred, Context
|
|
@@ -48,7 +48,10 @@ _OPTIONS = frozenset({"topic"})
|
|
|
48
48
|
class Change(BaseModel):
|
|
49
49
|
"""One record an action touched, by its pk; a frontend reads the record through the viewset's retrieve."""
|
|
50
50
|
|
|
51
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
52
|
+
|
|
51
53
|
pk: Any
|
|
54
|
+
"""Primary key of the record."""
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
class RecordsChanged(Event):
|
|
@@ -56,8 +59,12 @@ class RecordsChanged(Event):
|
|
|
56
59
|
|
|
57
60
|
event_name = "records_changed"
|
|
58
61
|
|
|
62
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
63
|
+
|
|
59
64
|
op: Literal["created", "updated", "deleted"]
|
|
65
|
+
"""What the action did to the records."""
|
|
60
66
|
changes: list[Change]
|
|
67
|
+
"""The records the action touched."""
|
|
61
68
|
|
|
62
69
|
|
|
63
70
|
def routed_base_path(request: "Request") -> str | None:
|
|
@@ -9,6 +9,7 @@ from typing import Any
|
|
|
9
9
|
from fastapi import APIRouter, Request
|
|
10
10
|
from pydantic import (
|
|
11
11
|
BaseModel,
|
|
12
|
+
ConfigDict,
|
|
12
13
|
Field,
|
|
13
14
|
GetJsonSchemaHandler,
|
|
14
15
|
model_serializer,
|
|
@@ -25,6 +26,7 @@ from ..decorators.route_viewset import route_viewset
|
|
|
25
26
|
from ..error_responses import ApiErrorBody, error_body
|
|
26
27
|
from ..exceptions import EventCursorsExceededError, EventTopicInvalidError
|
|
27
28
|
from ..mux_ws.transports import transports
|
|
29
|
+
from ..response_classes import POLL_ERROR_RESPONSES, SUBSCRIBE_ERROR_RESPONSES
|
|
28
30
|
from ..routing import PEER_SCOPE_KEY
|
|
29
31
|
from . import delivery
|
|
30
32
|
from .authorizers import authorize_topic
|
|
@@ -44,16 +46,21 @@ def _leave_out_none(data: Any, names: tuple[str, ...]) -> Any:
|
|
|
44
46
|
class TopicRequest(BaseModel):
|
|
45
47
|
"""The body of `subscribe` and `unsubscribe`."""
|
|
46
48
|
|
|
49
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
50
|
+
|
|
47
51
|
topic: str
|
|
52
|
+
"""The topic: a viewset's routed base path or a declared source's `name[:key]`."""
|
|
48
53
|
|
|
49
54
|
|
|
50
55
|
class SubscribeAnswer(BaseModel):
|
|
51
|
-
"""The answer of `subscribe`. `scope` is
|
|
56
|
+
"""The answer of `subscribe`. `scope` is absent for an unscoped topic."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
52
59
|
|
|
53
60
|
rev: int
|
|
54
61
|
"""The topic's current rev, 0 for a topic without events."""
|
|
55
62
|
scope: str | None = None
|
|
56
|
-
"""The scope key of a scoped viewset topic;
|
|
63
|
+
"""The scope key of a scoped viewset topic; absent for an unscoped topic."""
|
|
57
64
|
|
|
58
65
|
@model_serializer(mode="wrap")
|
|
59
66
|
def _leave_out_no_scope(self, handler: SerializerFunctionWrapHandler, _info: SerializationInfo) -> Any:
|
|
@@ -66,40 +73,52 @@ class SubscribeAnswer(BaseModel):
|
|
|
66
73
|
|
|
67
74
|
|
|
68
75
|
class PollRequest(BaseModel):
|
|
69
|
-
"""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."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
70
79
|
|
|
71
80
|
cursors: dict[str, int | None]
|
|
81
|
+
"""The rev the caller holds per topic; null to start from the topic's current rev."""
|
|
72
82
|
scopes: dict[str, str] | None = None
|
|
73
83
|
"""The scope key the caller holds per topic; a cursor whose scope key differs from the server's answers
|
|
74
|
-
`events: null`.
|
|
84
|
+
`events: null`. Absent or null trusts every cursor."""
|
|
75
85
|
|
|
76
86
|
|
|
77
87
|
class PolledEvent(BaseModel):
|
|
78
88
|
"""One event of a poll answer."""
|
|
79
89
|
|
|
90
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
91
|
+
|
|
80
92
|
rev: int
|
|
93
|
+
"""The event's rev in its topic."""
|
|
81
94
|
prev: int
|
|
82
95
|
"""The rev of the event before it in the answer; for the first event, the cursor."""
|
|
83
96
|
event: str
|
|
84
97
|
"""The event's `event_name`."""
|
|
85
98
|
version: int = Field(ge=1, description="The event's `event_version`: the version of the payload's shape.")
|
|
86
99
|
payload: Any = None
|
|
100
|
+
"""The event's fields."""
|
|
87
101
|
|
|
88
102
|
|
|
89
103
|
class TopicAnswer(BaseModel):
|
|
90
104
|
"""
|
|
91
|
-
The answer of `poll` for one topic. `events` is
|
|
92
|
-
caller then re-reads its state. A refused or invalid topic has `rev` and `events`
|
|
93
|
-
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.
|
|
94
108
|
"""
|
|
95
109
|
|
|
110
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
111
|
+
|
|
96
112
|
rev: int | None
|
|
113
|
+
"""The topic's current rev; null for a refused or invalid topic."""
|
|
97
114
|
events: list[PolledEvent] | None
|
|
115
|
+
"""The events after the cursor, oldest first; null when the log cannot answer the cursor or the topic is refused."""
|
|
98
116
|
error: ApiErrorBody | None = None
|
|
117
|
+
"""Why the topic is refused or invalid."""
|
|
99
118
|
status: int | None = None
|
|
100
119
|
"""The HTTP status of the refusal; set on every answer with `error`."""
|
|
101
120
|
scope: str | None = None
|
|
102
|
-
"""The scope key of a scoped viewset topic;
|
|
121
|
+
"""The scope key of a scoped viewset topic; absent for an unscoped topic."""
|
|
103
122
|
|
|
104
123
|
@model_serializer(mode="wrap")
|
|
105
124
|
def _leave_out_absent(self, handler: SerializerFunctionWrapHandler, _info: SerializationInfo) -> Any:
|
|
@@ -114,6 +133,8 @@ class TopicAnswer(BaseModel):
|
|
|
114
133
|
class PollAnswer(BaseModel):
|
|
115
134
|
"""The answer of `poll`."""
|
|
116
135
|
|
|
136
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
137
|
+
|
|
117
138
|
topics: dict[str, TopicAnswer]
|
|
118
139
|
"""One answer per cursor of the request, in the request's order."""
|
|
119
140
|
poll_after_ms: int
|
|
@@ -148,7 +169,7 @@ class EventsViewSet:
|
|
|
148
169
|
__router = APIRouter()
|
|
149
170
|
|
|
150
171
|
@transports(rest=False, muxws=True)
|
|
151
|
-
@__router.post("/subscribe")
|
|
172
|
+
@__router.post("/subscribe", responses=SUBSCRIBE_ERROR_RESPONSES)
|
|
152
173
|
async def subscribe(self, request: Request, context: Context, body: TopicRequest) -> SubscribeAnswer:
|
|
153
174
|
"""
|
|
154
175
|
Subscribes the socket to `topic` until it closes or unsubscribes, and answers the topic's current
|
|
@@ -156,7 +177,7 @@ class EventsViewSet:
|
|
|
156
177
|
|
|
157
178
|
Errors: 422 `fastapi-viewsets.event_topic_invalid` (malformed topic); 403
|
|
158
179
|
`fastapi-viewsets.event_topic_refused` or the authorizer's `HTTPException`; 403
|
|
159
|
-
`fastapi-viewsets.event_scope_missing` (scope
|
|
180
|
+
`fastapi-viewsets.event_scope_missing` (the topic has no scope key for the caller); 409
|
|
160
181
|
`fastapi-viewsets.event_subscriptions_exceeded` (`settings.viewsets_event_max_subscriptions`
|
|
161
182
|
reached). A 4xx refusal other than 429 also drops an existing subscription to the topic.
|
|
162
183
|
"""
|
|
@@ -185,10 +206,12 @@ class EventsViewSet:
|
|
|
185
206
|
await delivery.unsubscribe(_peer_of(request, "unsubscribe"), body.topic)
|
|
186
207
|
|
|
187
208
|
@transports(rest=True, muxws=True)
|
|
188
|
-
@__router.post(
|
|
209
|
+
@__router.post(
|
|
210
|
+
"/poll", responses=POLL_ERROR_RESPONSES, response_description="The answer of each topic of the body."
|
|
211
|
+
)
|
|
189
212
|
async def poll(self, request: Request, context: Context, body: PollRequest) -> PollAnswer:
|
|
190
213
|
"""
|
|
191
|
-
Answers every cursor: a
|
|
214
|
+
Answers every cursor: a null cursor gets the current rev and no events, any other the events
|
|
192
215
|
since it, or `events: null` when the log cannot answer it (or its `scopes` entry differs from
|
|
193
216
|
the server's scope key). Events refused by a delivery filter are left out; `prev` chains the
|
|
194
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)
|