dynamicforms-fastapi-viewsets 0.9.0__tar.gz → 0.9.1__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.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/PKG-INFO +18 -6
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/README.md +14 -2
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/collection_viewset.py +5 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/conf.py +48 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/route_viewset.py +38 -14
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/__init__.py +92 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/authorizers.py +252 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/changes.py +275 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/conftest.py +219 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/delivery.py +251 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/event.py +56 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/lifecycle.py +32 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/redis.py +259 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/sources.py +92 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/store.py +168 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/topics.py +47 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/viewset.py +224 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/exceptions.py +31 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/__init__.py +5 -6
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/__init__.py +3 -3
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/rate_limiter.py +2 -2
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/__init__.py +12 -12
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/asgi.py +4 -1
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/registry.py +1 -6
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/server.py +7 -1
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/routing.py +4 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/pyproject.toml +3 -3
- dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/__init__.py +0 -1
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
5
|
Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
|
|
6
6
|
Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
|
|
@@ -33,8 +33,8 @@ Requires-Dist: asgiref; extra == 'dev'
|
|
|
33
33
|
Requires-Dist: celery; extra == 'dev'
|
|
34
34
|
Requires-Dist: coverage; extra == 'dev'
|
|
35
35
|
Requires-Dist: django; extra == 'dev'
|
|
36
|
-
Requires-Dist:
|
|
37
|
-
Requires-Dist: muxws[starlette,websockets]>=0.4.
|
|
36
|
+
Requires-Dist: httpx2; extra == 'dev'
|
|
37
|
+
Requires-Dist: muxws[starlette,websockets]>=0.4.1; extra == 'dev'
|
|
38
38
|
Requires-Dist: pyjwt; extra == 'dev'
|
|
39
39
|
Requires-Dist: pytest; extra == 'dev'
|
|
40
40
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
@@ -46,7 +46,7 @@ Requires-Dist: django>=4.2; extra == 'django'
|
|
|
46
46
|
Provides-Extra: jwt
|
|
47
47
|
Requires-Dist: pyjwt>=2.8; extra == 'jwt'
|
|
48
48
|
Provides-Extra: muxws
|
|
49
|
-
Requires-Dist: muxws[starlette]>=0.4.
|
|
49
|
+
Requires-Dist: muxws[starlette]>=0.4.1; extra == 'muxws'
|
|
50
50
|
Description-Content-Type: text/markdown
|
|
51
51
|
|
|
52
52
|
# DynamicForms FastAPI Viewsets
|
|
@@ -71,6 +71,11 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
71
71
|
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
72
72
|
exception handlers, router dependencies and state - so both transports behave identically. In the
|
|
73
73
|
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
74
|
+
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
75
|
+
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
76
|
+
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
77
|
+
is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
|
|
78
|
+
follows its topic and `applyChanges()` applies each change to a list of rows.
|
|
74
79
|
- **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
|
|
75
80
|
and may let a client pick per request with an `X-List-Shape` header.
|
|
76
81
|
- **Declarative filters** — declare which fields accept which operators and get query parameters,
|
|
@@ -145,8 +150,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
|
|
|
145
150
|
the REST and muxws transports and between the in-memory and SQLite backends, and compare their
|
|
146
151
|
latency side by side. Sorting and filtering are server-side.
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
|
|
154
|
+
grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
|
|
155
|
+
muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
|
|
156
|
+
change: the row may have moved or left it.
|
|
157
|
+
|
|
158
|
+
`--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
|
|
159
|
+
publishes their events through Redis. It runs as one process, because the in-memory library is a
|
|
160
|
+
dict held by each process; the SQLite library is one file shared by every process. The end-to-end
|
|
161
|
+
suite starts its own backend and dev server on their own ports.
|
|
150
162
|
|
|
151
163
|
## License
|
|
152
164
|
|
|
@@ -20,6 +20,11 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
20
20
|
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
21
21
|
exception handlers, router dependencies and state - so both transports behave identically. In the
|
|
22
22
|
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
23
|
+
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
24
|
+
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
25
|
+
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
26
|
+
is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
|
|
27
|
+
follows its topic and `applyChanges()` applies each change to a list of rows.
|
|
23
28
|
- **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
|
|
24
29
|
and may let a client pick per request with an `X-List-Shape` header.
|
|
25
30
|
- **Declarative filters** — declare which fields accept which operators and get query parameters,
|
|
@@ -94,8 +99,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
|
|
|
94
99
|
the REST and muxws transports and between the in-memory and SQLite backends, and compare their
|
|
95
100
|
latency side by side. Sorting and filtering are server-side.
|
|
96
101
|
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
|
|
103
|
+
grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
|
|
104
|
+
muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
|
|
105
|
+
change: the row may have moved or left it.
|
|
106
|
+
|
|
107
|
+
`--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
|
|
108
|
+
publishes their events through Redis. It runs as one process, because the in-memory library is a
|
|
109
|
+
dict held by each process; the SQLite library is one file shared by every process. The end-to-end
|
|
110
|
+
suite starts its own backend and dev server on their own ports.
|
|
99
111
|
|
|
100
112
|
## License
|
|
101
113
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.1"
|
|
@@ -102,6 +102,11 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
102
102
|
self._update_func = self._update_mapping
|
|
103
103
|
self._delete_func = container.__delitem__
|
|
104
104
|
|
|
105
|
+
@property
|
|
106
|
+
def pk_field_name(self) -> str:
|
|
107
|
+
"""`pk_field`, the name the list's cursor shape and `ChangeEvents` read the pk under."""
|
|
108
|
+
return self.pk_field
|
|
109
|
+
|
|
105
110
|
def _check_mutable(self):
|
|
106
111
|
if not self.is_mutable:
|
|
107
112
|
raise ReadOnlyCollectionError()
|
{dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/conf.py
RENAMED
|
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
|
|
|
8
8
|
from fastapi.security.base import SecurityBase
|
|
9
9
|
|
|
10
10
|
from .context.auth import AuthBackend
|
|
11
|
+
from .events.authorizers import EventAuthorizer
|
|
12
|
+
from .events.delivery import DeliveryFilter
|
|
13
|
+
from .events.store import EventStore
|
|
11
14
|
from .middleware import CommandMiddleware
|
|
12
15
|
|
|
13
16
|
ContextProcessor = Callable[["Request", Any], Awaitable[dict[str, Any]]]
|
|
@@ -64,6 +67,51 @@ class Settings:
|
|
|
64
67
|
class is decorated with route_viewset, as `viewsets_command_middleware` must, which decides
|
|
65
68
|
`disable_response_model` when the routes are built.
|
|
66
69
|
"""
|
|
70
|
+
self.viewsets_event_store: EventStore | None = None
|
|
71
|
+
"""
|
|
72
|
+
The `EventStore` of `fastapi_viewsets.events`: it assigns each event its rev, keeps the
|
|
73
|
+
topic's log for `poll` and carries the event to `deliver()` in every process that watches
|
|
74
|
+
the topic. None is one `MemoryEventStore` for this process. A deployment of more than one
|
|
75
|
+
process, a Celery worker included, sets a `RedisEventStore` in every process.
|
|
76
|
+
"""
|
|
77
|
+
self.viewsets_event_authorizers: list[EventAuthorizer] = []
|
|
78
|
+
"""
|
|
79
|
+
The functions that decide whether a caller may follow a topic, consulted in list order on
|
|
80
|
+
every `subscribe` and on every `poll`, per topic. The first `True` admits the topic, the first
|
|
81
|
+
`False` refuses it, and no decision refuses it: an empty list refuses every topic.
|
|
82
|
+
"""
|
|
83
|
+
self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
|
|
84
|
+
"""
|
|
85
|
+
The functions that decide, per subscription and per event, whether a push is sent. A filter
|
|
86
|
+
that answers False skips that socket; a filter that raises counts as True.
|
|
87
|
+
"""
|
|
88
|
+
self.viewsets_event_log_size: int = 32
|
|
89
|
+
"""
|
|
90
|
+
The number of a topic's most recent events the store keeps for `poll`. 0 keeps none, and a
|
|
91
|
+
poll from any cursor behind the topic's rev answers `events: null`.
|
|
92
|
+
"""
|
|
93
|
+
self.viewsets_event_log_ttl: float = 60.0
|
|
94
|
+
"""
|
|
95
|
+
Seconds a topic's log is kept after the topic's last event. The topic's rev counter is kept
|
|
96
|
+
without expiry. `RedisEventStore` sets it as the log key's expiry; `MemoryEventStore` empties
|
|
97
|
+
an expired log when it next reads or appends to it.
|
|
98
|
+
"""
|
|
99
|
+
self.viewsets_event_push_timeout: float = 2.0
|
|
100
|
+
"""
|
|
101
|
+
Seconds the delivery filters and `peer.notify()` of one push of one event to one socket may
|
|
102
|
+
take together. `notify()` queues the push on the socket and returns without waiting for the
|
|
103
|
+
socket to send it. A push that takes longer is cancelled and logged at debug level.
|
|
104
|
+
"""
|
|
105
|
+
self.viewsets_event_max_subscriptions: int = 256
|
|
106
|
+
"""
|
|
107
|
+
The number of topics one socket may hold; a subscription beyond it raises
|
|
108
|
+
`EventSubscriptionsExceededError` (409). Also the number of cursors one poll may carry.
|
|
109
|
+
"""
|
|
110
|
+
self.viewsets_event_poll_after_ms: int = 0
|
|
111
|
+
"""
|
|
112
|
+
`poll_after_ms` of every poll answer: the shortest interval, in milliseconds, at which a
|
|
113
|
+
client polls.
|
|
114
|
+
"""
|
|
67
115
|
|
|
68
116
|
|
|
69
117
|
settings = Settings()
|
|
@@ -38,6 +38,14 @@ from .primary_key_model_helper import (
|
|
|
38
38
|
|
|
39
39
|
T = TypeVar("T")
|
|
40
40
|
|
|
41
|
+
MIDDLEWARE_DEPENDS_ATTRIBUTE = "__viewset_middleware_depends__"
|
|
42
|
+
"""
|
|
43
|
+
Set to True on the dependency `route_viewset` appends to each route's `dependencies`, which runs
|
|
44
|
+
`Middleware.depends()` of the effective middlewares. The other entries of a route's `dependencies`
|
|
45
|
+
are the application's: those of `FastAPI(dependencies=...)`, of `include_router`, of the
|
|
46
|
+
`APIRouter` and of the route itself.
|
|
47
|
+
"""
|
|
48
|
+
|
|
41
49
|
|
|
42
50
|
def build_type_map(cls: type[T], current_map: dict = None) -> dict:
|
|
43
51
|
if current_map is None:
|
|
@@ -131,11 +139,12 @@ def _make_middleware_depends(cls: type, action_name: str):
|
|
|
131
139
|
async def middleware_depends(request: Request) -> None:
|
|
132
140
|
await _run(request)
|
|
133
141
|
|
|
134
|
-
|
|
142
|
+
else:
|
|
135
143
|
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
async def middleware_depends(request: Request, _credential=Depends(scheme)) -> None: # noqa: B008
|
|
145
|
+
await _run(request)
|
|
138
146
|
|
|
147
|
+
setattr(middleware_depends, MIDDLEWARE_DEPENDS_ATTRIBUTE, True)
|
|
139
148
|
return middleware_depends
|
|
140
149
|
|
|
141
150
|
|
|
@@ -148,14 +157,17 @@ def route_viewset(
|
|
|
148
157
|
register_rest: bool = None,
|
|
149
158
|
):
|
|
150
159
|
"""
|
|
160
|
+
Sets `cls.__viewset_metadata__`, a dict of `base_path`, `lifecycle`, `router` (the argument),
|
|
161
|
+
`pk_field_name` (the argument, None without it) and `paths`: each action name, the endpoint
|
|
162
|
+
method's name, mapped to its route's path relative to `base_path` (`""` for `list_items`,
|
|
163
|
+
`"/{pk}"` for `retrieve`, `"/bulk"` for `bulk_create`). Every endpoint added to `router` carries
|
|
164
|
+
`__viewset_class__` (`cls`) and `__viewset_action__` (the action name); the `/schema` endpoints
|
|
165
|
+
carry the action name `"schema"`.
|
|
166
|
+
|
|
151
167
|
`register_rest` and `register_muxws` decide which transports this viewset is published on.
|
|
152
168
|
True registers, False does not, and None - the default - defers: REST to "yes", muxws to
|
|
153
169
|
`settings.viewsets_register_muxws`. Individual endpoints override either again with
|
|
154
170
|
`@transports(...)`.
|
|
155
|
-
|
|
156
|
-
Two parameters rather than one because there are two transports. If a third ever arrives, the
|
|
157
|
-
replacement is a single mapping - `transports={"rest": False}`, absent key meaning defer -
|
|
158
|
-
rather than a third `register_*`; see GAPS.md for why a set of flags is the worse shape.
|
|
159
171
|
"""
|
|
160
172
|
|
|
161
173
|
if lifecycle not in get_args(LifecycleType):
|
|
@@ -172,8 +184,7 @@ def route_viewset(
|
|
|
172
184
|
cls_name = cls_name[: -len("ViewSet")]
|
|
173
185
|
default_tags = [cls_name] if cls_name else None
|
|
174
186
|
# The viewset's own docstring becomes the description of the group its endpoints appear
|
|
175
|
-
# under
|
|
176
|
-
# documented. See endpoint_docs.apply_viewset_tags.
|
|
187
|
+
# under. See endpoint_docs.apply_viewset_tags.
|
|
177
188
|
if cls_name:
|
|
178
189
|
register_tag(cls_name, viewset_description(cls))
|
|
179
190
|
|
|
@@ -378,11 +389,13 @@ def route_viewset(
|
|
|
378
389
|
def is_schema_route(route) -> bool:
|
|
379
390
|
return getattr(route.endpoint, "__viewset_schema_endpoint__", False)
|
|
380
391
|
|
|
381
|
-
def add_route(route_kwargs: dict, transports: set[str]) -> None:
|
|
392
|
+
def add_route(route_kwargs: dict, transports: set[str], action_name: str) -> None:
|
|
382
393
|
# One route per endpoint on the application's router, whichever transports publish it,
|
|
383
394
|
# so both transports go through the application's middleware, exception handlers and
|
|
384
395
|
# router dependencies. The route matches only its own transports (see routing.py), and
|
|
385
396
|
# only a route published on REST appears in the application's OpenAPI document.
|
|
397
|
+
route_kwargs["endpoint"].__viewset_class__ = cls
|
|
398
|
+
route_kwargs["endpoint"].__viewset_action__ = action_name
|
|
386
399
|
router.add_api_route(
|
|
387
400
|
**{**route_kwargs, "include_in_schema": route_kwargs["include_in_schema"] and REST in transports},
|
|
388
401
|
route_class_override=transport_route_class(router.route_class, frozenset(transports)),
|
|
@@ -396,9 +409,14 @@ def route_viewset(
|
|
|
396
409
|
rest_routes = []
|
|
397
410
|
muxws_routes = []
|
|
398
411
|
documented = set()
|
|
412
|
+
# action name -> the route's path after base_path, so that a route's path on the application
|
|
413
|
+
# is the routed base path (include_router prefix + base_path) followed by it
|
|
414
|
+
paths: dict[str, str] = {}
|
|
415
|
+
base = base_path.rstrip("/")
|
|
399
416
|
for route in cls.__router.routes:
|
|
400
417
|
action_name = route.endpoint.__name__ # survives @wraps(original_endpoint) in get_wrapper
|
|
401
418
|
documented.add(action_name)
|
|
419
|
+
paths.setdefault(action_name, route.path[len(base) :] if route.path.startswith(base) else route.path)
|
|
402
420
|
dependencies = list(route.dependencies or []) + [Depends(_make_middleware_depends(cls, action_name))]
|
|
403
421
|
route_kwargs = route_to_add_api_route_kwargs(route, dependencies=dependencies)
|
|
404
422
|
# Per-viewset wording for an endpoint the mixin provided - the mixin's own docstring is
|
|
@@ -412,9 +430,9 @@ def route_viewset(
|
|
|
412
430
|
if on_rest:
|
|
413
431
|
rest_schema_kwargs = {**route_kwargs, "endpoint": openapi_endpoint(rest_routes)}
|
|
414
432
|
rest_routes.append(rest_schema_kwargs)
|
|
415
|
-
add_route(rest_schema_kwargs, {REST})
|
|
433
|
+
add_route(rest_schema_kwargs, {REST}, action_name)
|
|
416
434
|
if on_muxws_any:
|
|
417
|
-
add_route({**route_kwargs, "endpoint": openapi_endpoint(muxws_routes)}, {MUXWS})
|
|
435
|
+
add_route({**route_kwargs, "endpoint": openapi_endpoint(muxws_routes)}, {MUXWS}, action_name)
|
|
418
436
|
continue
|
|
419
437
|
|
|
420
438
|
on_muxws = resolve_register_muxws(route.endpoint, register_muxws)
|
|
@@ -426,7 +444,7 @@ def route_viewset(
|
|
|
426
444
|
transports.add(MUXWS)
|
|
427
445
|
muxws_routes.append(route_kwargs)
|
|
428
446
|
if transports:
|
|
429
|
-
add_route(route_kwargs, transports)
|
|
447
|
+
add_route(route_kwargs, transports, action_name)
|
|
430
448
|
|
|
431
449
|
# Checked here rather than in the decorator, which runs before any route exists. A name
|
|
432
450
|
# that matches nothing would otherwise leave that endpoint on the mixin's generic
|
|
@@ -441,7 +459,13 @@ def route_viewset(
|
|
|
441
459
|
if muxws_routes:
|
|
442
460
|
register_viewset(cls, base_path, muxws_routes, default_tags)
|
|
443
461
|
|
|
444
|
-
cls.__viewset_metadata__ = {
|
|
462
|
+
cls.__viewset_metadata__ = {
|
|
463
|
+
"base_path": base_path,
|
|
464
|
+
"lifecycle": lifecycle,
|
|
465
|
+
"router": router,
|
|
466
|
+
"pk_field_name": pk_field_name,
|
|
467
|
+
"paths": paths,
|
|
468
|
+
}
|
|
445
469
|
|
|
446
470
|
return cls
|
|
447
471
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Events: the backend publishes an event to a topic, and every frontend following the topic learns of
|
|
3
|
+
it, as a push over its muxws socket or on its next poll.
|
|
4
|
+
|
|
5
|
+
from fastapi_viewsets.events import Event, source
|
|
6
|
+
|
|
7
|
+
class ImportFinished(Event):
|
|
8
|
+
event_name = "import_finished"
|
|
9
|
+
|
|
10
|
+
file: str
|
|
11
|
+
rows: int
|
|
12
|
+
|
|
13
|
+
imports = source("imports")
|
|
14
|
+
|
|
15
|
+
await imports.publish(ImportFinished(file="members.csv", rows=412), key="7") # topic "imports:7"
|
|
16
|
+
|
|
17
|
+
Every event is recorded in the store (`settings.viewsets_event_store`) before it is pushed. The
|
|
18
|
+
application calls `start_events()` and `stop_events()` from its lifespan.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .authorizers import (
|
|
22
|
+
allow_all,
|
|
23
|
+
allow_authenticated,
|
|
24
|
+
authorize_topic,
|
|
25
|
+
DeclaredSources,
|
|
26
|
+
EventAuthorizer,
|
|
27
|
+
ViewsetTopics,
|
|
28
|
+
)
|
|
29
|
+
from .changes import Change, ChangeEvents, RecordsChanged, routed_base_path, TOPIC_CONTEXT_KEY
|
|
30
|
+
from .delivery import (
|
|
31
|
+
deliver,
|
|
32
|
+
DeliveryFilter,
|
|
33
|
+
drop_peer,
|
|
34
|
+
reset_subscriptions,
|
|
35
|
+
subscribe,
|
|
36
|
+
Subscription,
|
|
37
|
+
subscriptions,
|
|
38
|
+
topics_of,
|
|
39
|
+
unsubscribe,
|
|
40
|
+
wait_for_pushes,
|
|
41
|
+
)
|
|
42
|
+
from .event import Event
|
|
43
|
+
from .lifecycle import check_events, start_events, stop_events
|
|
44
|
+
from .sources import declared_source, publish, reset_sources, Source, source
|
|
45
|
+
from .store import EventStore, get_store, LoggedEvent, MAX_REV, MemoryEventStore
|
|
46
|
+
from .topics import is_valid_topic, is_viewset_topic, SOURCE_NAME_PATTERN, split_custom_topic, validate_topic
|
|
47
|
+
from .viewset import EventsViewSet, register_events
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"MAX_REV",
|
|
51
|
+
"SOURCE_NAME_PATTERN",
|
|
52
|
+
"TOPIC_CONTEXT_KEY",
|
|
53
|
+
"Change",
|
|
54
|
+
"ChangeEvents",
|
|
55
|
+
"DeclaredSources",
|
|
56
|
+
"DeliveryFilter",
|
|
57
|
+
"Event",
|
|
58
|
+
"EventAuthorizer",
|
|
59
|
+
"EventStore",
|
|
60
|
+
"EventsViewSet",
|
|
61
|
+
"LoggedEvent",
|
|
62
|
+
"MemoryEventStore",
|
|
63
|
+
"RecordsChanged",
|
|
64
|
+
"Source",
|
|
65
|
+
"Subscription",
|
|
66
|
+
"ViewsetTopics",
|
|
67
|
+
"allow_all",
|
|
68
|
+
"allow_authenticated",
|
|
69
|
+
"authorize_topic",
|
|
70
|
+
"check_events",
|
|
71
|
+
"declared_source",
|
|
72
|
+
"deliver",
|
|
73
|
+
"drop_peer",
|
|
74
|
+
"get_store",
|
|
75
|
+
"is_valid_topic",
|
|
76
|
+
"is_viewset_topic",
|
|
77
|
+
"publish",
|
|
78
|
+
"register_events",
|
|
79
|
+
"reset_sources",
|
|
80
|
+
"reset_subscriptions",
|
|
81
|
+
"routed_base_path",
|
|
82
|
+
"source",
|
|
83
|
+
"split_custom_topic",
|
|
84
|
+
"start_events",
|
|
85
|
+
"stop_events",
|
|
86
|
+
"subscribe",
|
|
87
|
+
"subscriptions",
|
|
88
|
+
"topics_of",
|
|
89
|
+
"unsubscribe",
|
|
90
|
+
"validate_topic",
|
|
91
|
+
"wait_for_pushes",
|
|
92
|
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The functions that decide whether a caller may follow a topic, and the authorizers shipped with the
|
|
3
|
+
package. `settings.viewsets_event_authorizers` lists the ones an application uses:
|
|
4
|
+
|
|
5
|
+
settings.viewsets_event_authorizers = [ViewsetTopics(), DeclaredSources()]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
11
|
+
from contextlib import AsyncExitStack
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi import Request
|
|
16
|
+
from fastapi.dependencies.models import Dependant
|
|
17
|
+
from fastapi.dependencies.utils import get_parameterless_sub_dependant, solve_dependencies
|
|
18
|
+
from starlette.routing import compile_path
|
|
19
|
+
|
|
20
|
+
from ..action_configuration import extra_middlewares_for, resolve_action_configuration
|
|
21
|
+
from ..conf import settings
|
|
22
|
+
from ..context import Context
|
|
23
|
+
from ..decorators.route_viewset import MIDDLEWARE_DEPENDS_ATTRIBUTE
|
|
24
|
+
from ..exceptions import EventTopicRefusedError
|
|
25
|
+
from ..middleware import Middleware
|
|
26
|
+
from .sources import declared_source
|
|
27
|
+
from .topics import is_viewset_topic, split_custom_topic
|
|
28
|
+
|
|
29
|
+
EventAuthorizer = Callable[[Request, Context, str], Awaitable[bool | None]]
|
|
30
|
+
"""
|
|
31
|
+
`await authorizer(request, context, topic)`: True admits the topic, False refuses it, None leaves
|
|
32
|
+
the decision to the next authorizer in `settings.viewsets_event_authorizers`. An `HTTPException` it
|
|
33
|
+
raises refuses the topic with that exception.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def authorize_topic(request: Request, context: Context, topic: str) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Runs `settings.viewsets_event_authorizers` in list order for `topic` and returns when one
|
|
40
|
+
admits it. The first True admits the topic; the first False, or no decision at all, raises
|
|
41
|
+
`EventTopicRefusedError` (403). An exception an authorizer raises, an `HTTPException` included,
|
|
42
|
+
propagates and the authorizers after it do not run. An authorizer may also return its decision
|
|
43
|
+
without an awaitable.
|
|
44
|
+
"""
|
|
45
|
+
for authorizer in settings.viewsets_event_authorizers:
|
|
46
|
+
decision = authorizer(request, context, topic)
|
|
47
|
+
if inspect.isawaitable(decision):
|
|
48
|
+
decision = await decision
|
|
49
|
+
if decision is None:
|
|
50
|
+
continue
|
|
51
|
+
if decision:
|
|
52
|
+
return
|
|
53
|
+
break
|
|
54
|
+
raise EventTopicRefusedError(topic)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def allow_all(_request: Request, _context: Context, _topic: str) -> bool:
|
|
58
|
+
"""Admits every topic."""
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def allow_authenticated(_request: Request, context: Context, _topic: str) -> bool | None:
|
|
63
|
+
"""Admits a topic when `await context.user` is truthy; None otherwise, and for a context without
|
|
64
|
+
`user`."""
|
|
65
|
+
if "user" not in context:
|
|
66
|
+
return None
|
|
67
|
+
return True if await context.user else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DeclaredSources:
|
|
71
|
+
"""
|
|
72
|
+
Admits a custom topic through the `authorizer` of the source that declared its name: the answer
|
|
73
|
+
is that authorizer's. A viewset topic, a name no source declared, and a source declared without
|
|
74
|
+
an authorizer get None.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
async def __call__(self, request: Request, context: Context, topic: str) -> bool | None:
|
|
78
|
+
parts = split_custom_topic(topic)
|
|
79
|
+
if parts is None:
|
|
80
|
+
return None
|
|
81
|
+
declared = declared_source(parts[0])
|
|
82
|
+
if declared is None or declared.authorizer is None:
|
|
83
|
+
return None
|
|
84
|
+
decision = declared.authorizer(request, context, topic)
|
|
85
|
+
if inspect.isawaitable(decision):
|
|
86
|
+
decision = await decision
|
|
87
|
+
return decision
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return "DeclaredSources()"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _routes(app: Any) -> Iterator[Any]:
|
|
94
|
+
"""Every route of `app` with every `include_router` prefix and dependency applied: its `path`,
|
|
95
|
+
`endpoint` and `dependencies` are those the application serves the route with."""
|
|
96
|
+
try:
|
|
97
|
+
from fastapi.routing import iter_route_contexts
|
|
98
|
+
except ImportError: # a FastAPI whose include_router copies the routes, prefixed, onto the app
|
|
99
|
+
yield from app.routes
|
|
100
|
+
return
|
|
101
|
+
yield from iter_route_contexts(app.routes)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _gate_action(cls: type) -> str | None:
|
|
105
|
+
"""The action whose admission admits a caller to `cls`'s topic: `list_items`, else `retrieve`."""
|
|
106
|
+
paths = getattr(cls, "__viewset_metadata__", {}).get("paths", {})
|
|
107
|
+
for action in ("list_items", "retrieve"):
|
|
108
|
+
if action in paths:
|
|
109
|
+
return action
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class ViewsetMatch:
|
|
115
|
+
"""The viewset a topic belongs to, as `match_viewset` finds it."""
|
|
116
|
+
|
|
117
|
+
viewset_class: type
|
|
118
|
+
action: str
|
|
119
|
+
"""`list_items`, or `retrieve` for a class without `list_items`."""
|
|
120
|
+
route: Any
|
|
121
|
+
"""The application's route of `action`."""
|
|
122
|
+
path_params: dict[str, Any]
|
|
123
|
+
"""The values of the path parameters of the routed base path, read from the topic and converted
|
|
124
|
+
as Starlette converts them for a request."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def match_viewset(app: Any, topic: str) -> ViewsetMatch | None:
|
|
128
|
+
"""
|
|
129
|
+
The first route of `app`, in routing order, whose endpoint carries `__viewset_class__` and, as
|
|
130
|
+
`__viewset_action__`, the class's `list_items`, or `retrieve` for a class without `list_items`,
|
|
131
|
+
and whose path without that action's path in `__viewset_metadata__["paths"]` matches `topic`
|
|
132
|
+
as Starlette matches a request path, path parameters included: `/clubs/{club_id}/tracks`
|
|
133
|
+
matches the topic `/clubs/7/tracks`. None when no route matches.
|
|
134
|
+
"""
|
|
135
|
+
for route in _routes(app):
|
|
136
|
+
endpoint = getattr(route, "endpoint", None)
|
|
137
|
+
cls = getattr(endpoint, "__viewset_class__", None)
|
|
138
|
+
path = getattr(route, "path", None)
|
|
139
|
+
if cls is None or not isinstance(path, str):
|
|
140
|
+
continue
|
|
141
|
+
action = _gate_action(cls)
|
|
142
|
+
if action is None or getattr(endpoint, "__viewset_action__", None) != action:
|
|
143
|
+
continue
|
|
144
|
+
relative = cls.__viewset_metadata__["paths"][action]
|
|
145
|
+
if not path.endswith(relative):
|
|
146
|
+
continue
|
|
147
|
+
regex, _format, convertors = compile_path(path[: len(path) - len(relative)] if relative else path)
|
|
148
|
+
matched = regex.match(topic)
|
|
149
|
+
if matched is None:
|
|
150
|
+
continue
|
|
151
|
+
params = {name: convertors[name].convert(value) for name, value in matched.groupdict().items()}
|
|
152
|
+
return ViewsetMatch(cls, action, route, params)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def find_viewset(app: Any, topic: str) -> tuple[type, str] | None:
|
|
157
|
+
"""`(viewset class, action)` of `match_viewset(app, topic)`, or None."""
|
|
158
|
+
found = match_viewset(app, topic)
|
|
159
|
+
return (found.viewset_class, found.action) if found is not None else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _with_path_params(request: Request, path_params: dict[str, Any]) -> Request:
|
|
163
|
+
"""`request`, or a copy of it whose `path_params` are `path_params` when there are any."""
|
|
164
|
+
if not path_params:
|
|
165
|
+
return request
|
|
166
|
+
return Request({**request.scope, "path_params": path_params}, request.receive)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _route_dependencies_admit(request: Request, route: Any) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Resolves the route's `dependencies` other than the one that runs `Middleware.depends()` (those of
|
|
172
|
+
the application, of `include_router`, of the `APIRouter` and of the route) against `request`,
|
|
173
|
+
as FastAPI resolves them before the endpoint, honouring `app.dependency_overrides`. False when a
|
|
174
|
+
parameter of one does not validate; an exception one raises propagates.
|
|
175
|
+
"""
|
|
176
|
+
dependencies = [
|
|
177
|
+
depends
|
|
178
|
+
for depends in getattr(route, "dependencies", None) or ()
|
|
179
|
+
if not getattr(depends.dependency, MIDDLEWARE_DEPENDS_ATTRIBUTE, False)
|
|
180
|
+
]
|
|
181
|
+
if not dependencies:
|
|
182
|
+
return True
|
|
183
|
+
path = getattr(route, "path_format", None) or route.path
|
|
184
|
+
dependant = Dependant(
|
|
185
|
+
path=path,
|
|
186
|
+
dependencies=[get_parameterless_sub_dependant(depends=depends, path=path) for depends in dependencies],
|
|
187
|
+
)
|
|
188
|
+
accepted = inspect.signature(solve_dependencies).parameters
|
|
189
|
+
async with AsyncExitStack() as stack:
|
|
190
|
+
scope = {
|
|
191
|
+
**request.scope,
|
|
192
|
+
"fastapi_astack": stack,
|
|
193
|
+
"fastapi_inner_astack": stack,
|
|
194
|
+
"fastapi_function_astack": stack,
|
|
195
|
+
}
|
|
196
|
+
arguments: dict[str, Any] = {
|
|
197
|
+
"request": Request(scope, request.receive),
|
|
198
|
+
"dependant": dependant,
|
|
199
|
+
"dependency_overrides_provider": getattr(route, "dependency_overrides_provider", None),
|
|
200
|
+
}
|
|
201
|
+
if "async_exit_stack" in accepted:
|
|
202
|
+
arguments["async_exit_stack"] = stack
|
|
203
|
+
if "embed_body_fields" in accepted:
|
|
204
|
+
arguments["embed_body_fields"] = False
|
|
205
|
+
solved = await solve_dependencies(**arguments)
|
|
206
|
+
errors = solved.errors if hasattr(solved, "errors") else solved[1]
|
|
207
|
+
return not errors
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ViewsetTopics:
|
|
211
|
+
"""
|
|
212
|
+
Admits a viewset's topic to a caller the viewset itself admits to its `list_items`, or to its
|
|
213
|
+
`retrieve` when it has no `list_items`.
|
|
214
|
+
|
|
215
|
+
A topic that does not start with `/`, and a topic no viewset on `request.app` has, get None (see
|
|
216
|
+
`match_viewset`). For the viewset found, the checks a request to that action passes run against
|
|
217
|
+
the calling request, which carries the path parameters read from the topic as `path_params`:
|
|
218
|
+
|
|
219
|
+
1. the route's `dependencies` other than `route_viewset`'s own, those of the application, of
|
|
220
|
+
`include_router`, of the `APIRouter` and of the route itself; a parameter of one that does not
|
|
221
|
+
validate refuses the topic (False);
|
|
222
|
+
2. `depends()` of every effective middleware, with the viewset class and the calling context
|
|
223
|
+
rebound to the action's `@action_configuration`, `Context(context.raw(),
|
|
224
|
+
action_configuration=..., action_name=...)`: `settings.viewsets_command_middleware` followed
|
|
225
|
+
by the middleware instances the configuration adds.
|
|
226
|
+
|
|
227
|
+
An `HTTPException` propagates: `Session` raises 401, `Authorization` 403, `RateLimiter` 429 and
|
|
228
|
+
counts the call as one call of that action. When none raises, the topic is admitted.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
async def __call__(self, request: Request, context: Context, topic: str) -> bool | None:
|
|
232
|
+
if not is_viewset_topic(topic):
|
|
233
|
+
return None
|
|
234
|
+
found = match_viewset(request.app, topic)
|
|
235
|
+
if found is None:
|
|
236
|
+
return None
|
|
237
|
+
cls, action = found.viewset_class, found.action
|
|
238
|
+
checked = _with_path_params(request, found.path_params)
|
|
239
|
+
if not await _route_dependencies_admit(checked, found.route):
|
|
240
|
+
return False
|
|
241
|
+
action_configuration = resolve_action_configuration(cls, action)
|
|
242
|
+
bound = Context(context.raw(), action_configuration=action_configuration, action_name=action)
|
|
243
|
+
middlewares = settings.viewsets_command_middleware + extra_middlewares_for(
|
|
244
|
+
action_configuration, settings.viewsets_command_middleware
|
|
245
|
+
)
|
|
246
|
+
for middleware in middlewares:
|
|
247
|
+
if isinstance(middleware, Middleware):
|
|
248
|
+
await middleware.depends(checked, cls, bound)
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
def __repr__(self) -> str:
|
|
252
|
+
return "ViewsetTopics()"
|