dynamicforms-fastapi-viewsets 0.11.2__tar.gz → 0.11.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/PKG-INFO +5 -6
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/README.md +2 -3
- dynamicforms_fastapi_viewsets-0.11.4/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/collection_viewset.py +1 -2
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/conf.py +13 -5
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/client.py +1 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +6 -4
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/server.py +3 -5
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/__init__.py +2 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/conftest.py +2 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/delivery.py +19 -2
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/redis.py +117 -14
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/__init__.py +8 -10
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/__init__.py +4 -10
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/asgi.py +3 -6
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/protocol.py +7 -9
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/server.py +1 -4
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/pyproject.toml +2 -2
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/mux_ws/origin.py +0 -103
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/route_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/scopes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/exceptions.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/idempotency.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/routing.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/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.11.
|
|
3
|
+
Version: 0.11.4
|
|
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
|
|
@@ -35,7 +35,7 @@ Requires-Dist: coverage; extra == 'dev'
|
|
|
35
35
|
Requires-Dist: django; extra == 'dev'
|
|
36
36
|
Requires-Dist: gevent; extra == 'dev'
|
|
37
37
|
Requires-Dist: httpx2; extra == 'dev'
|
|
38
|
-
Requires-Dist: muxws[starlette,websockets]>=0.4.
|
|
38
|
+
Requires-Dist: muxws[starlette,websockets]>=0.4.2; extra == 'dev'
|
|
39
39
|
Requires-Dist: pyjwt; extra == 'dev'
|
|
40
40
|
Requires-Dist: pytest; extra == 'dev'
|
|
41
41
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
@@ -49,7 +49,7 @@ Requires-Dist: django>=4.2; extra == 'django'
|
|
|
49
49
|
Provides-Extra: jwt
|
|
50
50
|
Requires-Dist: pyjwt>=2.8; extra == 'jwt'
|
|
51
51
|
Provides-Extra: muxws
|
|
52
|
-
Requires-Dist: muxws[starlette]>=0.4.
|
|
52
|
+
Requires-Dist: muxws[starlette]>=0.4.2; extra == 'muxws'
|
|
53
53
|
Description-Content-Type: text/markdown
|
|
54
54
|
|
|
55
55
|
# DynamicForms FastAPI Viewsets
|
|
@@ -72,8 +72,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
72
72
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
73
73
|
- **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
|
|
74
74
|
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
75
|
-
exception handlers, router dependencies and state - so both transports behave identically.
|
|
76
|
-
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
75
|
+
exception handlers, router dependencies and state - so both transports behave identically.
|
|
77
76
|
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
78
77
|
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
79
78
|
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
@@ -139,7 +138,7 @@ app.include_router(router)
|
|
|
139
138
|
|
|
140
139
|
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
141
140
|
the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
|
|
142
|
-
list pipeline and pagination, the muxws transport, and the Vue client.
|
|
141
|
+
list pipeline and pagination, the muxws transport, events, and the Vue client.
|
|
143
142
|
|
|
144
143
|
## Demo
|
|
145
144
|
|
|
@@ -18,8 +18,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
18
18
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
19
19
|
- **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
|
|
20
20
|
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
21
|
-
exception handlers, router dependencies and state - so both transports behave identically.
|
|
22
|
-
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
21
|
+
exception handlers, router dependencies and state - so both transports behave identically.
|
|
23
22
|
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
24
23
|
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
25
24
|
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
@@ -85,7 +84,7 @@ app.include_router(router)
|
|
|
85
84
|
|
|
86
85
|
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
87
86
|
the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
|
|
88
|
-
list pipeline and pagination, the muxws transport, and the Vue client.
|
|
87
|
+
list pipeline and pagination, the muxws transport, events, and the Vue client.
|
|
89
88
|
|
|
90
89
|
## Demo
|
|
91
90
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.11.4"
|
|
@@ -211,8 +211,7 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
211
211
|
|
|
212
212
|
for field_name, field_info in data.__class__.model_fields.items():
|
|
213
213
|
if field_info.json_schema_extra and field_info.json_schema_extra.get("autoinc_int"):
|
|
214
|
-
#
|
|
215
|
-
# However, we usually want autoinc only if the value is not yet set
|
|
214
|
+
# a truthy value is kept; 0 or None gets the next value after the container's maximum
|
|
216
215
|
current_val = getattr(data, field_name, None)
|
|
217
216
|
if current_val:
|
|
218
217
|
continue
|
|
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
|
|
|
9
9
|
|
|
10
10
|
from .context.auth import AuthBackend
|
|
11
11
|
from .events.authorizers import EventAuthorizer
|
|
12
|
-
from .events.delivery import DeliveryFilter
|
|
12
|
+
from .events.delivery import DeliveryFilter, EventListener
|
|
13
13
|
from .events.store import EventStore
|
|
14
14
|
from .middleware import CommandMiddleware
|
|
15
15
|
|
|
@@ -40,8 +40,9 @@ class Settings:
|
|
|
40
40
|
"""
|
|
41
41
|
self.viewsets_idempotency_lock_ttl: float = 600
|
|
42
42
|
"""
|
|
43
|
-
Seconds a running record of `Idempotency` is kept after it was
|
|
44
|
-
|
|
43
|
+
Seconds a running record of `Idempotency` is kept after it was last extended; the process
|
|
44
|
+
running the call extends it every third of this time. Once it expires, a repeat runs the
|
|
45
|
+
action again.
|
|
45
46
|
"""
|
|
46
47
|
self.viewsets_auth_processors: list[AuthBackend] = []
|
|
47
48
|
self.default_action_configuration: dict[Any, Any] = {}
|
|
@@ -82,8 +83,15 @@ class Settings:
|
|
|
82
83
|
"""
|
|
83
84
|
self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
|
|
84
85
|
"""
|
|
85
|
-
The functions that decide, per subscription and per event, whether a push is sent
|
|
86
|
-
|
|
86
|
+
The functions that decide, per subscription and per event, whether a push is sent, and per
|
|
87
|
+
poll and per event whether the poll answers the event. A filter that answers False skips that
|
|
88
|
+
socket or leaves the event out of the poll answer; a filter that raises counts as True.
|
|
89
|
+
"""
|
|
90
|
+
self.viewsets_event_listeners: list[EventListener] = []
|
|
91
|
+
"""
|
|
92
|
+
The functions called with every event in every process whose store is started, before its
|
|
93
|
+
pushes, whether or not the process holds a subscription of its topic. A listener that raises is
|
|
94
|
+
logged and the next one runs.
|
|
87
95
|
"""
|
|
88
96
|
self.viewsets_event_log_size: int = 32
|
|
89
97
|
"""
|
|
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
|
|
|
27
27
|
T = TypeVar("T")
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
30
|
+
# Seconds a `celery_viewset_client` call waits for its result when `result_timeout` is not given.
|
|
30
31
|
DEFAULT_RESULT_TIMEOUT = 3600.0
|
|
31
32
|
|
|
32
33
|
# queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
|
|
@@ -136,8 +136,9 @@ def _payload_error(data: dict) -> Exception:
|
|
|
136
136
|
def error_payload(correlation_id: str, task: str | None, error: BaseException) -> dict:
|
|
137
137
|
"""
|
|
138
138
|
The payload that fails one call with `error`: an `HTTPException` (a `DfViewSetError` with its
|
|
139
|
-
class),
|
|
140
|
-
|
|
139
|
+
class), a `RequestValidationError` as a 422 with its errors as `detail`, any other error as
|
|
140
|
+
`ActionFailedError`. `http_status_code` and `http_detail` repeat the status and detail for a
|
|
141
|
+
reader that does not decode `error`.
|
|
141
142
|
"""
|
|
142
143
|
if not isinstance(error, (HTTPException, RequestValidationError)):
|
|
143
144
|
error = ActionFailedError(task)
|
|
@@ -168,8 +169,9 @@ def push_result(
|
|
|
168
169
|
"""
|
|
169
170
|
Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
|
|
170
171
|
the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
|
|
171
|
-
of a class `fastapi_viewsets.exceptions` defines keeps its class);
|
|
172
|
-
as
|
|
172
|
+
of a class `fastapi_viewsets.exceptions` defines keeps its class); a `RequestValidationError`
|
|
173
|
+
reaches it as an `HTTPException` 422 with its errors as `detail`; any other error reaches it as
|
|
174
|
+
`ActionFailedError`. `task` names the call's task in that error.
|
|
173
175
|
"""
|
|
174
176
|
if error is not None:
|
|
175
177
|
data = error_payload(correlation_id, task, error)
|
|
@@ -63,11 +63,9 @@ def _unwrap_optional(hint):
|
|
|
63
63
|
return hint
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
#
|
|
67
|
-
# SerializableObject values
|
|
68
|
-
#
|
|
69
|
-
# ViewSetResult's own (de)serialization. Kept under this name since call sites/tests here already
|
|
70
|
-
# import it as `_to_jsonable`.
|
|
66
|
+
# `fastapi_viewsets.context.to_jsonable`: converts Pydantic models, dicts, lists/tuples and
|
|
67
|
+
# SerializableObject values (e.g. a ViewSetResult a worker-side action returned directly); also used
|
|
68
|
+
# by ViewSetResult's own (de)serialization.
|
|
71
69
|
_to_jsonable = to_jsonable
|
|
72
70
|
|
|
73
71
|
|
|
@@ -32,6 +32,7 @@ from .delivery import (
|
|
|
32
32
|
DeliveryFilter,
|
|
33
33
|
drop_peer,
|
|
34
34
|
drop_subscriptions,
|
|
35
|
+
EventListener,
|
|
35
36
|
passes_filters,
|
|
36
37
|
reset_subscriptions,
|
|
37
38
|
subscribe,
|
|
@@ -80,6 +81,7 @@ __all__ = [
|
|
|
80
81
|
"ChangeEvents",
|
|
81
82
|
"DeclaredSources",
|
|
82
83
|
"DeliveryFilter",
|
|
84
|
+
"EventListener",
|
|
83
85
|
"Event",
|
|
84
86
|
"EventAuthorizer",
|
|
85
87
|
"EventScope",
|
|
@@ -19,6 +19,7 @@ EVENT_SETTINGS = (
|
|
|
19
19
|
"viewsets_event_store",
|
|
20
20
|
"viewsets_event_authorizers",
|
|
21
21
|
"viewsets_event_delivery_filters",
|
|
22
|
+
"viewsets_event_listeners",
|
|
22
23
|
"viewsets_event_log_size",
|
|
23
24
|
"viewsets_event_log_ttl",
|
|
24
25
|
"viewsets_event_push_timeout",
|
|
@@ -33,6 +34,7 @@ def _reset_events():
|
|
|
33
34
|
saved = {name: getattr(settings, name) for name in EVENT_SETTINGS}
|
|
34
35
|
settings.viewsets_event_authorizers = []
|
|
35
36
|
settings.viewsets_event_delivery_filters = []
|
|
37
|
+
settings.viewsets_event_listeners = []
|
|
36
38
|
reset_subscriptions()
|
|
37
39
|
reset_sources()
|
|
38
40
|
reset_event_scopes()
|
|
@@ -62,6 +62,14 @@ None for a poll over REST. `topic` is the client topic; `subscription.store_topi
|
|
|
62
62
|
`subscription.scope` name the scope of a scoped topic.
|
|
63
63
|
"""
|
|
64
64
|
|
|
65
|
+
EventListener = Callable[[str, int, int, str, Any, int], "None | Awaitable[None]"]
|
|
66
|
+
"""
|
|
67
|
+
`listener(topic, rev, prev, event_name, payload, version)`, sync or async; `topic` is the store
|
|
68
|
+
topic. `deliver()` calls the listeners with every event, in every process whose store is started,
|
|
69
|
+
whether or not the process holds a subscription of the topic, before it pushes the event; a listener
|
|
70
|
+
that removes subscriptions (`drop_subscriptions`) removes them before the push.
|
|
71
|
+
"""
|
|
72
|
+
|
|
65
73
|
# store topic -> (id(peer), client topic) -> Subscription
|
|
66
74
|
_by_topic: dict[str, dict[tuple[int, str], Subscription]] = {}
|
|
67
75
|
# id(peer) -> client topic -> Subscription
|
|
@@ -316,8 +324,10 @@ async def _push(
|
|
|
316
324
|
|
|
317
325
|
async def deliver(topic: str, rev: int, prev: int, name: str, payload: Any, version: int) -> None:
|
|
318
326
|
"""
|
|
319
|
-
|
|
320
|
-
|
|
327
|
+
Calls `settings.viewsets_event_listeners` in list order with the event, awaiting each; a listener
|
|
328
|
+
that raises is logged and the next one runs. Then pushes the event of the store topic `topic` to
|
|
329
|
+
every subscription delivered under it in this process, each push in a task of its own, and
|
|
330
|
+
returns without waiting for them. A push runs the
|
|
321
331
|
subscription's reauthorization when it is due (see `settings.viewsets_event_reauthorize_after`),
|
|
322
332
|
the delivery filters with the client topic, and then `peer.notify(payload, headers={":event":
|
|
323
333
|
name, ":version": version, ":topic": subscription.topic, ":rev": rev, ":prev": prev})`, with
|
|
@@ -325,6 +335,13 @@ async def deliver(topic: str, rev: int, prev: int, name: str, payload: Any, vers
|
|
|
325
335
|
`settings.viewsets_event_push_timeout` seconds. A push that raises a `ConnectionLost` drops
|
|
326
336
|
every subscription of its socket; any other failure is logged at debug level.
|
|
327
337
|
"""
|
|
338
|
+
for listener in settings.viewsets_event_listeners:
|
|
339
|
+
try:
|
|
340
|
+
result = listener(topic, rev, prev, name, payload, version)
|
|
341
|
+
if inspect.isawaitable(result):
|
|
342
|
+
await result
|
|
343
|
+
except Exception:
|
|
344
|
+
logger.exception("events: a listener raised for topic %s rev %s", topic, rev)
|
|
328
345
|
for subscription in subscriptions(topic):
|
|
329
346
|
_spawn(_push(subscription, topic, rev, prev, name, payload, version))
|
|
330
347
|
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
2
|
`RedisEventStore`: the event store shared by every process that reaches one Redis.
|
|
3
3
|
|
|
4
|
-
The only module of `fastapi_viewsets.events` that uses `redis`.
|
|
5
|
-
client the application constructs,
|
|
4
|
+
The only module of `fastapi_viewsets.events` that uses `redis`. `RedisEventStore` takes a
|
|
5
|
+
`redis.asyncio.Redis` client the application constructs, or builds one client per event loop from a
|
|
6
|
+
URL (`RedisEventStore.from_url`, `PerLoopRedis`); the `redis` package is imported by `PerLoopRedis`
|
|
7
|
+
only.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
import asyncio
|
|
9
11
|
import json
|
|
10
12
|
import logging
|
|
13
|
+
import threading
|
|
11
14
|
|
|
12
15
|
from typing import Any, TYPE_CHECKING
|
|
13
16
|
|
|
@@ -47,10 +50,78 @@ return {rev_text, prev_text}
|
|
|
47
50
|
"""
|
|
48
51
|
|
|
49
52
|
# Seconds the reader waits for a message before it checks again, and waits after a failed read.
|
|
50
|
-
|
|
53
|
+
DEFAULT_READ_TIMEOUT = 1.0
|
|
51
54
|
|
|
52
55
|
# Seconds `start` waits for Redis to confirm the SUBSCRIBE of the events channel.
|
|
53
|
-
|
|
56
|
+
DEFAULT_SUBSCRIBE_TIMEOUT = 5.0
|
|
57
|
+
|
|
58
|
+
# The `redis.asyncio.Redis.from_url` options `PerLoopRedis` sets unless given: seconds a connect and
|
|
59
|
+
# a single command may take.
|
|
60
|
+
DEFAULT_CLIENT_OPTIONS: dict[str, Any] = {"socket_connect_timeout": 2.0, "socket_timeout": 5.0}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PerLoopRedis:
|
|
64
|
+
"""
|
|
65
|
+
Stands in for a `redis.asyncio.Redis`: every attribute whose name does not start with `_` is
|
|
66
|
+
read off the client of the running event loop, created with
|
|
67
|
+
`Redis.from_url(url, **(DEFAULT_CLIENT_OPTIONS | options))` on its first use in that loop. A
|
|
68
|
+
`redis.asyncio` connection belongs to the loop it was opened on, and one process runs the store
|
|
69
|
+
on several loops: the server's, the loops of a Celery worker's executions, and the loop
|
|
70
|
+
`asgiref`'s `async_to_sync()` opens for a call without a running loop to hand it to (WSGI,
|
|
71
|
+
management commands, plain threads).
|
|
72
|
+
|
|
73
|
+
A loop's client is closed and forgotten when the loop's tasks are cancelled at its end, as
|
|
74
|
+
`asyncio.run()` does. A client used on that loop by another task's cancellation cleanup may be
|
|
75
|
+
closed already. The client of a loop closed without that, and of a loop alive at process exit, is
|
|
76
|
+
not closed: it is forgotten by the next call that creates a client, and its connections close
|
|
77
|
+
when it is garbage-collected.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, url: str, **options: Any):
|
|
81
|
+
from redis.asyncio import Redis
|
|
82
|
+
|
|
83
|
+
self.url = url
|
|
84
|
+
self.options = DEFAULT_CLIENT_OPTIONS | options
|
|
85
|
+
self._redis_class = Redis
|
|
86
|
+
self._clients: dict[asyncio.AbstractEventLoop, Redis] = {}
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def client(self) -> "Redis":
|
|
90
|
+
"""The client of the running loop. Raises `RuntimeError` outside a running loop."""
|
|
91
|
+
loop = asyncio.get_running_loop()
|
|
92
|
+
with self._lock:
|
|
93
|
+
client = self._clients.get(loop)
|
|
94
|
+
if client is not None:
|
|
95
|
+
return client
|
|
96
|
+
for closed in [held for held in self._clients if held.is_closed()]:
|
|
97
|
+
del self._clients[closed]
|
|
98
|
+
client = self._clients[loop] = self._redis_class.from_url(self.url, **self.options)
|
|
99
|
+
closer = loop.create_task(self._close_with_loop(loop, client), name="PerLoopRedis: close with the loop")
|
|
100
|
+
# A loop that ends without cancelling its tasks destroys this one without a warning.
|
|
101
|
+
closer._log_destroy_pending = False # type: ignore[attr-defined]
|
|
102
|
+
return client
|
|
103
|
+
|
|
104
|
+
async def _close_with_loop(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
|
|
105
|
+
try:
|
|
106
|
+
await loop.create_future()
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
self._forget(loop, client)
|
|
109
|
+
await client.aclose()
|
|
110
|
+
raise
|
|
111
|
+
except GeneratorExit:
|
|
112
|
+
# The task is destroyed with its loop: there is no loop to close the client on.
|
|
113
|
+
self._forget(loop, client)
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
def _forget(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._clients.get(loop) is client:
|
|
119
|
+
del self._clients[loop]
|
|
120
|
+
|
|
121
|
+
def __getattr__(self, name: str) -> Any:
|
|
122
|
+
if name.startswith("_"):
|
|
123
|
+
raise AttributeError(name)
|
|
124
|
+
return getattr(self.client(), name)
|
|
54
125
|
|
|
55
126
|
|
|
56
127
|
class RedisEventStore:
|
|
@@ -67,19 +138,52 @@ class RedisEventStore:
|
|
|
67
138
|
`append` is one Lua script. `start()` SUBSCRIBEs the events channel on one pub/sub connection,
|
|
68
139
|
returns once Redis has confirmed it, and runs the reader, a task that calls `deliver()` for each
|
|
69
140
|
message; `deliver()` pushes the event to the subscriptions of its topic in this process and
|
|
70
|
-
leaves out a topic without one. `stop()` closes the connection.
|
|
141
|
+
leaves out a topic without one. `stop()` closes the connection. `start()` and `stop()` run on
|
|
142
|
+
one loop, the loop the reader runs on.
|
|
143
|
+
|
|
144
|
+
`subscribe_timeout` is the seconds `start()` waits for Redis to confirm the SUBSCRIBE;
|
|
145
|
+
`read_timeout` the seconds the reader waits for a message before it checks again, and waits after
|
|
146
|
+
a failed read.
|
|
71
147
|
|
|
72
148
|
When redis-py reconnects the pub/sub connection it SUBSCRIBEs the channel again. An event
|
|
73
149
|
published while the connection is down does not reach this process; the event client finds it
|
|
74
150
|
by poll, on the topic's next push or its check poll.
|
|
75
151
|
"""
|
|
76
152
|
|
|
77
|
-
def __init__(
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
redis_client: "Redis | PerLoopRedis",
|
|
156
|
+
key_prefix: str = "events:",
|
|
157
|
+
*,
|
|
158
|
+
subscribe_timeout: float = DEFAULT_SUBSCRIBE_TIMEOUT,
|
|
159
|
+
read_timeout: float = DEFAULT_READ_TIMEOUT,
|
|
160
|
+
):
|
|
78
161
|
self.redis_client = redis_client
|
|
79
162
|
self.key_prefix = key_prefix
|
|
163
|
+
self.subscribe_timeout = subscribe_timeout
|
|
164
|
+
self.read_timeout = read_timeout
|
|
80
165
|
self._pubsub: Any = None
|
|
81
166
|
self._reader: asyncio.Task | None = None
|
|
82
167
|
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_url(
|
|
170
|
+
cls,
|
|
171
|
+
url: str,
|
|
172
|
+
key_prefix: str = "events:",
|
|
173
|
+
*,
|
|
174
|
+
subscribe_timeout: float = DEFAULT_SUBSCRIBE_TIMEOUT,
|
|
175
|
+
read_timeout: float = DEFAULT_READ_TIMEOUT,
|
|
176
|
+
**options: Any,
|
|
177
|
+
) -> "RedisEventStore":
|
|
178
|
+
"""A store on `PerLoopRedis(url, **options)`: one client per event loop. `options` are
|
|
179
|
+
`redis.asyncio.Redis.from_url` options over `DEFAULT_CLIENT_OPTIONS`."""
|
|
180
|
+
return cls(
|
|
181
|
+
PerLoopRedis(url, **options),
|
|
182
|
+
key_prefix,
|
|
183
|
+
subscribe_timeout=subscribe_timeout,
|
|
184
|
+
read_timeout=read_timeout,
|
|
185
|
+
)
|
|
186
|
+
|
|
83
187
|
def rev_key(self, topic: str) -> str:
|
|
84
188
|
return f"{self.key_prefix}rev:{topic}"
|
|
85
189
|
|
|
@@ -99,25 +203,24 @@ class RedisEventStore:
|
|
|
99
203
|
async def start(self) -> None:
|
|
100
204
|
"""
|
|
101
205
|
SUBSCRIBEs the events channel and starts the reader once Redis has confirmed the SUBSCRIBE.
|
|
102
|
-
Raises `asyncio.TimeoutError` when the confirmation does not arrive within
|
|
103
|
-
the error of the connection when it fails; the store is then not started.
|
|
206
|
+
Raises `asyncio.TimeoutError` when the confirmation does not arrive within `subscribe_timeout`
|
|
207
|
+
seconds, and the error of the connection when it fails; the store is then not started.
|
|
104
208
|
"""
|
|
105
209
|
if self.reader_running:
|
|
106
210
|
return
|
|
107
211
|
pubsub = self.redis_client.pubsub()
|
|
108
212
|
try:
|
|
109
213
|
await pubsub.subscribe(self.channel)
|
|
110
|
-
await asyncio.wait_for(self._confirmed(pubsub),
|
|
214
|
+
await asyncio.wait_for(self._confirmed(pubsub), self.subscribe_timeout)
|
|
111
215
|
except BaseException:
|
|
112
216
|
await _close(pubsub)
|
|
113
217
|
raise
|
|
114
218
|
self._pubsub = pubsub
|
|
115
219
|
self._reader = asyncio.create_task(self._read())
|
|
116
220
|
|
|
117
|
-
|
|
118
|
-
async def _confirmed(pubsub: Any) -> None:
|
|
221
|
+
async def _confirmed(self, pubsub: Any) -> None:
|
|
119
222
|
while True:
|
|
120
|
-
message = await pubsub.get_message(ignore_subscribe_messages=False, timeout=
|
|
223
|
+
message = await pubsub.get_message(ignore_subscribe_messages=False, timeout=self.read_timeout)
|
|
121
224
|
if message is not None and message.get("type") == "subscribe":
|
|
122
225
|
return
|
|
123
226
|
|
|
@@ -185,12 +288,12 @@ class RedisEventStore:
|
|
|
185
288
|
async def _read(self) -> None:
|
|
186
289
|
while True:
|
|
187
290
|
try:
|
|
188
|
-
message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=
|
|
291
|
+
message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=self.read_timeout)
|
|
189
292
|
except asyncio.CancelledError:
|
|
190
293
|
raise
|
|
191
294
|
except Exception:
|
|
192
295
|
logger.exception("events: reading the events channel failed")
|
|
193
|
-
await asyncio.sleep(
|
|
296
|
+
await asyncio.sleep(self.read_timeout)
|
|
194
297
|
continue
|
|
195
298
|
if message is None or message.get("type") != "message":
|
|
196
299
|
continue
|
|
@@ -216,16 +216,14 @@ class Middleware(ABC):
|
|
|
216
216
|
the onion chain below, exactly as if this method didn't exist.
|
|
217
217
|
|
|
218
218
|
`cls` is always the viewset *class*, never an instance - for `per-request`/`instance-key`
|
|
219
|
-
lifecycles no instance exists yet this early
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
`middleware.auth.authorization.Authorization`, which splits across both methods for exactly
|
|
228
|
-
this reason) before body parsing, so that kind of work stays in `__call__` as it does today.
|
|
219
|
+
lifecycles no instance exists yet this early. `context` is real and usable here (see
|
|
220
|
+
`fastapi_viewsets.context.get_shared_context`) - built as early as this method can possibly
|
|
221
|
+
run, which for `per-request`/`instance-key` lifecycles is *before* `load_state()`; no shipped
|
|
222
|
+
context processor reads viewset instance state.
|
|
223
|
+
|
|
224
|
+
Put here *only* logic that may reject. Unconditional work (e.g. enriching context with no
|
|
225
|
+
reject path) belongs in `__call__`; `middleware.auth.authorization.Authorization` splits
|
|
226
|
+
across both methods this way.
|
|
229
227
|
For middleware whose entire job is reject-or-allow with nothing unconditional (`Session`,
|
|
230
228
|
`RateLimiter`), `__call__` is `return await call_next()`: `depends()` makes the only
|
|
231
229
|
decision, and if it rejects, `__call__`/the onion chain is not reached (the exception
|
|
@@ -16,18 +16,14 @@ Server side:
|
|
|
16
16
|
from fastapi import FastAPI, WebSocket
|
|
17
17
|
from muxws import accept
|
|
18
18
|
|
|
19
|
-
from fastapi_viewsets.mux_ws import
|
|
19
|
+
from fastapi_viewsets.mux_ws import process_command
|
|
20
20
|
|
|
21
21
|
@app.websocket("/ws")
|
|
22
22
|
async def muxws_endpoint(websocket: WebSocket) -> None:
|
|
23
|
-
# A cookie-authenticated WebSocket has no CSRF protection: refuse a foreign Origin before
|
|
24
|
-
# accept(), which the server answers with HTTP 403.
|
|
25
|
-
if not origin_allowed(websocket):
|
|
26
|
-
await websocket.close(code=1008)
|
|
27
|
-
return
|
|
28
23
|
# Do not call websocket.accept() yourself - muxws performs the upgrade, because only it
|
|
29
|
-
# knows which muxws.v1.<codec> subprotocol to select.
|
|
30
|
-
|
|
24
|
+
# knows which muxws.v1.<codec> subprotocol to select. A cookie-authenticated WebSocket has
|
|
25
|
+
# no CSRF protection: allowed_origins refuses a foreign Origin with HTTP 403.
|
|
26
|
+
peer = await accept(websocket, allowed_origins=["https://app.example"])
|
|
31
27
|
peer.on_stream(
|
|
32
28
|
lambda payload, stream: process_command(payload, stream, connection=websocket, peer=peer)
|
|
33
29
|
)
|
|
@@ -38,7 +34,6 @@ Which viewsets get registered is decided at three levels, each able to defer to
|
|
|
38
34
|
`settings.viewsets_register_muxws` globally (default True).
|
|
39
35
|
"""
|
|
40
36
|
|
|
41
|
-
from .origin import origin_allowed
|
|
42
37
|
from .protocol import METHOD_KEY, PATH_KEY, QUERY_KEY, STATUS_KEY
|
|
43
38
|
from .registry import (
|
|
44
39
|
is_registered,
|
|
@@ -58,7 +53,6 @@ __all__ = [
|
|
|
58
53
|
"QUERY_KEY",
|
|
59
54
|
"STATUS_KEY",
|
|
60
55
|
"is_registered",
|
|
61
|
-
"origin_allowed",
|
|
62
56
|
"process_command",
|
|
63
57
|
"register_viewset",
|
|
64
58
|
"registered_viewsets",
|
|
@@ -5,9 +5,7 @@ The muxws transport deliberately does not re-implement parameter binding, valida
|
|
|
5
5
|
resolution or response-model serialisation - it hands the request to the application, the same one
|
|
6
6
|
an HTTP server hands a request to, so the two transports cannot drift apart on anything the
|
|
7
7
|
application declares. The scope marks the request as a muxws one (`fastapi_viewsets.routing`).
|
|
8
|
-
|
|
9
|
-
What that costs is one synthetic ASGI round trip per call, which is still nothing next to an
|
|
10
|
-
actual HTTP request: no connection setup, no header parsing, no socket at all.
|
|
8
|
+
Each call is one in-process ASGI round trip.
|
|
11
9
|
"""
|
|
12
10
|
|
|
13
11
|
import json
|
|
@@ -129,9 +127,8 @@ def payload_to_body(payload: Any) -> bytes:
|
|
|
129
127
|
"""
|
|
130
128
|
Re-encodes an already-decoded muxws payload as the JSON bytes FastAPI expects to parse.
|
|
131
129
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
ourselves, which is exactly the duplication this transport exists to avoid. See GAPS.md.
|
|
130
|
+
FastAPI parses the request body from bytes, so a decoded payload is encoded again as JSON;
|
|
131
|
+
`bytes` pass through unchanged and an absent or `None` payload is an empty body.
|
|
135
132
|
"""
|
|
136
133
|
from muxws.frames import ABSENT
|
|
137
134
|
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
"""
|
|
2
2
|
The request/response envelope carried over a muxws stream.
|
|
3
3
|
|
|
4
|
-
muxws
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
HTTP headers, since a header field name may not contain a colon. Everything in the open frame's
|
|
12
|
-
headers that is *not* `:`-prefixed is passed through as an ordinary HTTP header.
|
|
4
|
+
muxws does no routing (SPEC WSM-AUT-004: it does not look at the opening payload to pick a
|
|
5
|
+
handler), so the open frame carries the address. The envelope follows HTTP/2: an `open` frame
|
|
6
|
+
carries `headers` plus a `payload`, as HTTP/2 carries a HEADERS frame followed by DATA.
|
|
7
|
+
|
|
8
|
+
Method, path and query string travel as HTTP/2-style pseudo-headers, `:`-prefixed. They cannot
|
|
9
|
+
collide with HTTP headers, because a header field name may not contain a colon. Every header of
|
|
10
|
+
the open frame that is not `:`-prefixed is passed through as an ordinary HTTP header.
|
|
13
11
|
|
|
14
12
|
peer.open(
|
|
15
13
|
payload={"title": "Kind of Blue"}, # the request body
|
|
@@ -76,10 +76,7 @@ async def process_command(
|
|
|
76
76
|
|
|
77
77
|
@app.websocket("/ws")
|
|
78
78
|
async def endpoint(websocket: WebSocket) -> None:
|
|
79
|
-
|
|
80
|
-
await websocket.close(code=1008) # before accept(): the handshake is answered with HTTP 403
|
|
81
|
-
return
|
|
82
|
-
peer = await accept(websocket)
|
|
79
|
+
peer = await accept(websocket, allowed_origins=["https://app.example"])
|
|
83
80
|
|
|
84
81
|
async def on_stream(payload, stream):
|
|
85
82
|
if await process_command(payload, stream, connection=websocket, peer=peer):
|
{dynamicforms_fastapi_viewsets-0.11.2 → dynamicforms_fastapi_viewsets-0.11.4}/pyproject.toml
RENAMED
|
@@ -34,7 +34,7 @@ dependencies = [
|
|
|
34
34
|
|
|
35
35
|
[project.optional-dependencies]
|
|
36
36
|
celery = ["celery>=5.3", "redis>=5.0"]
|
|
37
|
-
muxws = ["muxws[starlette]>=0.4.
|
|
37
|
+
muxws = ["muxws[starlette]>=0.4.2"]
|
|
38
38
|
django = ["django>=4.2", "asgiref>=3.6"]
|
|
39
39
|
jwt = ["PyJWT>=2.8"]
|
|
40
40
|
dev = [
|
|
@@ -49,7 +49,7 @@ dev = [
|
|
|
49
49
|
"django",
|
|
50
50
|
"asgiref",
|
|
51
51
|
"PyJWT",
|
|
52
|
-
"muxws[starlette,websockets]>=0.4.
|
|
52
|
+
"muxws[starlette,websockets]>=0.4.2",
|
|
53
53
|
"ruff",
|
|
54
54
|
"uvicorn",
|
|
55
55
|
]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.11.2"
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
`origin_allowed`, the `Origin` check of a WebSocket endpoint.
|
|
3
|
-
|
|
4
|
-
A browser sends the cookies of the endpoint's site with a WebSocket handshake whatever page opened
|
|
5
|
-
it, and a WebSocket has no CSRF protection of its own: an endpoint whose session is a cookie answers
|
|
6
|
-
a handshake from another site's page with that session unless it checks `Origin` before `accept()`.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from collections.abc import Collection
|
|
10
|
-
from typing import TYPE_CHECKING
|
|
11
|
-
from urllib.parse import urlsplit
|
|
12
|
-
|
|
13
|
-
if TYPE_CHECKING:
|
|
14
|
-
from starlette.requests import HTTPConnection
|
|
15
|
-
|
|
16
|
-
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _parse_origin(value: str) -> tuple[str, str, int] | None:
|
|
20
|
-
"""`(scheme, host, port)` of a serialized origin, `scheme://host[:port]` with an optional
|
|
21
|
-
trailing `/`, scheme and host lower-cased and the port explicit; None for anything else."""
|
|
22
|
-
text = value.strip()
|
|
23
|
-
if text.endswith("/"):
|
|
24
|
-
text = text[:-1]
|
|
25
|
-
try:
|
|
26
|
-
parts = urlsplit(text)
|
|
27
|
-
port = parts.port
|
|
28
|
-
except ValueError:
|
|
29
|
-
return None
|
|
30
|
-
scheme = parts.scheme.lower()
|
|
31
|
-
if scheme not in _DEFAULT_PORTS or not parts.hostname:
|
|
32
|
-
return None
|
|
33
|
-
if parts.path or parts.query or parts.fragment or parts.username is not None or parts.password is not None:
|
|
34
|
-
return None
|
|
35
|
-
if "@" in parts.netloc:
|
|
36
|
-
return None
|
|
37
|
-
return scheme, parts.hostname.lower(), port if port is not None else _DEFAULT_PORTS[scheme]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _parse_host(value: str, default_port: int) -> tuple[str, int] | None:
|
|
41
|
-
"""`(host, port)` of a `Host` header value, the port `default_port` when it names none."""
|
|
42
|
-
try:
|
|
43
|
-
parts = urlsplit(f"//{value.strip()}")
|
|
44
|
-
port = parts.port
|
|
45
|
-
except ValueError:
|
|
46
|
-
return None
|
|
47
|
-
if not parts.hostname or parts.path or parts.query or parts.fragment or "@" in parts.netloc:
|
|
48
|
-
return None
|
|
49
|
-
return parts.hostname.lower(), port if port is not None else default_port
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def origin_allowed(connection: "HTTPConnection", allowed_origins: Collection[str] | None = None) -> bool:
|
|
53
|
-
"""
|
|
54
|
-
Whether the `Origin` of `connection` (a `WebSocket` or a `Request`) may open the endpoint.
|
|
55
|
-
|
|
56
|
-
- No `Origin` header (a client that is not a browser): True. More than one: False.
|
|
57
|
-
- `Origin: null`, and an origin that is not `scheme://host[:port]` with the scheme `http` or
|
|
58
|
-
`https` (a path, a query, userinfo, another scheme): False.
|
|
59
|
-
- Origins compare as `(scheme, host, port)`, scheme and host lower-cased, the port the scheme's
|
|
60
|
-
default (80, 443) when the origin names none, a trailing `/` removed.
|
|
61
|
-
- With `allowed_origins`: True when the origin equals one of its entries. An entry that is not
|
|
62
|
-
an origin raises `ValueError`.
|
|
63
|
-
- Without it: True when the origin's `(host, port)` equals that of the request's host, the first
|
|
64
|
-
item of `X-Forwarded-Host` when the request carries one, else `Host`; a host without a port
|
|
65
|
-
takes the default port of the origin's scheme. The scheme is not compared, so a TLS proxy in
|
|
66
|
-
front of the application passes. No host at all: False. A proxy that rewrites `Host` without
|
|
67
|
-
setting `X-Forwarded-Host` needs `allowed_origins`.
|
|
68
|
-
|
|
69
|
-
The endpoint calls it before `accept()` and closes a refused handshake, which the server answers
|
|
70
|
-
with HTTP 403:
|
|
71
|
-
|
|
72
|
-
if not origin_allowed(websocket):
|
|
73
|
-
await websocket.close(code=1008)
|
|
74
|
-
return
|
|
75
|
-
peer = await accept(websocket)
|
|
76
|
-
"""
|
|
77
|
-
allowed = None
|
|
78
|
-
if allowed_origins is not None:
|
|
79
|
-
allowed = set()
|
|
80
|
-
for entry in allowed_origins:
|
|
81
|
-
parsed = _parse_origin(entry)
|
|
82
|
-
if parsed is None:
|
|
83
|
-
raise ValueError(f"{entry!r} in allowed_origins is not an origin: scheme://host[:port]")
|
|
84
|
-
allowed.add(parsed)
|
|
85
|
-
|
|
86
|
-
values = connection.headers.getlist("origin")
|
|
87
|
-
if not values:
|
|
88
|
-
return True
|
|
89
|
-
if len(values) > 1:
|
|
90
|
-
return False
|
|
91
|
-
origin = _parse_origin(values[0])
|
|
92
|
-
if origin is None:
|
|
93
|
-
return False
|
|
94
|
-
if allowed is not None:
|
|
95
|
-
return origin in allowed
|
|
96
|
-
|
|
97
|
-
scheme, host, port = origin
|
|
98
|
-
forwarded = connection.headers.get("x-forwarded-host")
|
|
99
|
-
request_host = forwarded.split(",")[0] if forwarded else connection.headers.get("host")
|
|
100
|
-
if not request_host or not request_host.strip():
|
|
101
|
-
return False
|
|
102
|
-
parsed_host = _parse_host(request_host, _DEFAULT_PORTS[scheme])
|
|
103
|
-
return parsed_host == (host, port)
|
|
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
|