dynamicforms-fastapi-viewsets 0.5.6__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/PKG-INFO +1 -1
- dynamicforms_fastapi_viewsets-0.6.0/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/backends/django_orm.py +2 -2
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/collection_viewset.py +1 -1
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/cursor.py +23 -5
- dynamicforms_fastapi_viewsets-0.6.0/fastapi_viewsets/exceptions.py +81 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/auth/__init__.py +2 -3
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/auth/authorization.py +17 -12
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/rate_limiter.py +2 -3
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mixins.py +4 -6
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/protocol.py +2 -2
- dynamicforms_fastapi_viewsets-0.6.0/fastapi_viewsets/response_classes.py +8 -0
- dynamicforms_fastapi_viewsets-0.5.6/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.5.6/fastapi_viewsets/response_classes.py +0 -17
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/conf.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
5
|
Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
|
|
6
6
|
Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -27,12 +27,12 @@ from typing import Any, ClassVar, Generic, TYPE_CHECKING
|
|
|
27
27
|
|
|
28
28
|
from asgiref.sync import sync_to_async
|
|
29
29
|
from django.db.models import BooleanField, F, Func, Q, Value
|
|
30
|
-
from fastapi import HTTPException
|
|
31
30
|
from pydantic import BaseModel
|
|
32
31
|
from typing_extensions import TypeVar
|
|
33
32
|
|
|
34
33
|
from ..context import Context
|
|
35
34
|
from ..cursor import CursorPredicate, wants_greater
|
|
35
|
+
from ..exceptions import NotFoundError
|
|
36
36
|
from ..filters import (
|
|
37
37
|
can_compile_all,
|
|
38
38
|
compile_all,
|
|
@@ -299,7 +299,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
299
299
|
try:
|
|
300
300
|
return await self.get_queryset(context).aget(**{self.pk_field: pk})
|
|
301
301
|
except ObjectDoesNotExist:
|
|
302
|
-
raise
|
|
302
|
+
raise NotFoundError(pk) from None
|
|
303
303
|
|
|
304
304
|
def _writable_fields(self, data: T, exclude_unset: bool = False) -> dict[str, Any]:
|
|
305
305
|
"""
|
|
@@ -5,8 +5,8 @@ from typing import Any, Generic, TypeVar
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
|
|
7
7
|
from .context import Context
|
|
8
|
+
from .exceptions import NotFoundError
|
|
8
9
|
from .mixins import ImplMixin
|
|
9
|
-
from .response_classes import NotFoundError
|
|
10
10
|
|
|
11
11
|
T = TypeVar("T")
|
|
12
12
|
K = TypeVar("K")
|
|
@@ -45,6 +45,11 @@ TItem = TypeVar("TItem")
|
|
|
45
45
|
class CursorError(ValueError):
|
|
46
46
|
"""The cursor is unreadable, or does not belong to the query it was sent with."""
|
|
47
47
|
|
|
48
|
+
def __init__(self, message: str, code: str, params: dict[str, Any] | None = None):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code = code
|
|
51
|
+
self.params = params or {}
|
|
52
|
+
|
|
48
53
|
|
|
49
54
|
CursorKeys = tuple[tuple[str, bool], ...]
|
|
50
55
|
"""Ordering keys as (field name, descending), primary key last. The shape a position is read against."""
|
|
@@ -129,18 +134,27 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
|
|
|
129
134
|
padded = raw + "=" * (-len(raw) % 4)
|
|
130
135
|
payload = json.loads(base64.urlsafe_b64decode(padded))
|
|
131
136
|
except (ValueError, binascii.Error) as error:
|
|
132
|
-
raise CursorError(
|
|
137
|
+
raise CursorError(
|
|
138
|
+
f"cursor is not readable: {error}", code="cursor_unreadable", params={"error": str(error)}
|
|
139
|
+
) from None
|
|
133
140
|
|
|
134
141
|
if not isinstance(payload, dict) or not isinstance(payload.get("p"), dict):
|
|
135
|
-
raise CursorError("cursor is not readable: no position in it")
|
|
142
|
+
raise CursorError("cursor is not readable: no position in it", code="cursor_missing_position")
|
|
136
143
|
|
|
137
144
|
if payload.get("q") != expected_query:
|
|
138
|
-
raise CursorError(
|
|
145
|
+
raise CursorError(
|
|
146
|
+
"this cursor was issued for a different ordering or filter - start from the first page",
|
|
147
|
+
code="cursor_stale",
|
|
148
|
+
)
|
|
139
149
|
|
|
140
150
|
position = payload["p"]
|
|
141
151
|
missing = [name for name, _ in keys if name not in position]
|
|
142
152
|
if missing:
|
|
143
|
-
raise CursorError(
|
|
153
|
+
raise CursorError(
|
|
154
|
+
f"cursor has no value for ordering key(s): {', '.join(missing)}",
|
|
155
|
+
code="cursor_missing_keys",
|
|
156
|
+
params={"missing": missing},
|
|
157
|
+
)
|
|
144
158
|
|
|
145
159
|
coerced: dict[str, Any] = {}
|
|
146
160
|
for name, _ in keys:
|
|
@@ -152,7 +166,11 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
|
|
|
152
166
|
try:
|
|
153
167
|
coerced[name] = TypeAdapter(annotation).validate_python(value)
|
|
154
168
|
except Exception as error:
|
|
155
|
-
raise CursorError(
|
|
169
|
+
raise CursorError(
|
|
170
|
+
f"cursor value for {name!r} does not fit the field: {error}",
|
|
171
|
+
code="cursor_value_mismatch",
|
|
172
|
+
params={"name": name, "error": str(error)},
|
|
173
|
+
) from None
|
|
156
174
|
|
|
157
175
|
return CursorState(
|
|
158
176
|
position=coerced,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The HTTP exceptions this package raises on its own behalf.
|
|
3
|
+
|
|
4
|
+
`detail` is the English default, fully interpolated - a plain string, exactly as it always was, so
|
|
5
|
+
an application that does nothing further sees exactly what it always has. `code` names what went
|
|
6
|
+
wrong independently of `detail`'s wording, and `params` are the raw values `detail` was
|
|
7
|
+
interpolated with; both reach the response body only once the application registers
|
|
8
|
+
`df_viewset_exception_handler` for `DfViewSetError` (see that function). A view's own
|
|
9
|
+
`raise HTTPException(status_code, detail="...")` is unaffected either way.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import HTTPException, Request
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from .cursor import CursorError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DfViewSetError(HTTPException):
|
|
21
|
+
"""Base for every `HTTPException` this package raises on its own behalf."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_code: int, message: str, code: str, params: dict[str, Any] | None = None):
|
|
24
|
+
super().__init__(status_code=status_code, detail=message)
|
|
25
|
+
self.code = code
|
|
26
|
+
self.params = params or {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotFoundError(DfViewSetError):
|
|
30
|
+
def __init__(self, pk: Any):
|
|
31
|
+
super().__init__(404, f"Item with pk {pk} not found", "not_found", {"pk": pk})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionExpiredError(DfViewSetError):
|
|
35
|
+
def __init__(self):
|
|
36
|
+
super().__init__(401, "Session expired or invalid", "session_expired")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotAuthorizedError(DfViewSetError):
|
|
40
|
+
def __init__(self):
|
|
41
|
+
super().__init__(403, "Not authorized to perform this action", "not_authorized")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RateLimitedError(DfViewSetError):
|
|
45
|
+
def __init__(self):
|
|
46
|
+
super().__init__(429, "Rate limit exceeded", "rate_limited")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnsupportedListShapeError(DfViewSetError):
|
|
50
|
+
def __init__(self, shape: str, allowed: list[str]):
|
|
51
|
+
message = f"unsupported list shape {shape!r}; this endpoint offers {', '.join(allowed)}"
|
|
52
|
+
super().__init__(422, message, "unsupported_list_shape", {"shape": shape, "allowed": allowed})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CursorRequestError(DfViewSetError):
|
|
56
|
+
"""Raised in place of a caught `CursorError`, carrying the same `code`/`params` as a 400 response."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, error: CursorError):
|
|
59
|
+
super().__init__(400, str(error), error.code, error.params)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def df_viewset_exception_handler(_request: Request, exc: DfViewSetError) -> JSONResponse:
|
|
63
|
+
"""
|
|
64
|
+
Adds `detail_code` and `detail_params` alongside `detail` in the response body.
|
|
65
|
+
|
|
66
|
+
Opt in once, for `DfViewSetError` - every exception this package raises on its own descends
|
|
67
|
+
from it:
|
|
68
|
+
|
|
69
|
+
from fastapi_viewsets.exceptions import DfViewSetError, df_viewset_exception_handler
|
|
70
|
+
|
|
71
|
+
app.add_exception_handler(DfViewSetError, df_viewset_exception_handler)
|
|
72
|
+
|
|
73
|
+
An application that never registers this sees exactly what it always has -
|
|
74
|
+
`{"detail": "..."}`, from FastAPI's own default `HTTPException` handling; `code` and `params`
|
|
75
|
+
reach nothing without it.
|
|
76
|
+
"""
|
|
77
|
+
return JSONResponse(
|
|
78
|
+
status_code=exc.status_code,
|
|
79
|
+
content={"detail": exc.detail, "detail_code": exc.code, "detail_params": exc.params},
|
|
80
|
+
headers=exc.headers,
|
|
81
|
+
)
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from collections.abc import Awaitable, Callable
|
|
2
2
|
from typing import Any, TYPE_CHECKING
|
|
3
3
|
|
|
4
|
-
from
|
|
5
|
-
|
|
4
|
+
from ...exceptions import SessionExpiredError
|
|
6
5
|
from .. import Middleware, ViewSetResult
|
|
7
6
|
|
|
8
7
|
if TYPE_CHECKING:
|
|
@@ -41,7 +40,7 @@ class Session(Middleware):
|
|
|
41
40
|
return
|
|
42
41
|
user = await context.user
|
|
43
42
|
if user is None:
|
|
44
|
-
raise
|
|
43
|
+
raise SessionExpiredError()
|
|
45
44
|
|
|
46
45
|
async def __call__(
|
|
47
46
|
self,
|
|
@@ -3,8 +3,7 @@ import inspect
|
|
|
3
3
|
from collections.abc import Awaitable, Callable
|
|
4
4
|
from typing import Any, TYPE_CHECKING
|
|
5
5
|
|
|
6
|
-
from
|
|
7
|
-
|
|
6
|
+
from ...exceptions import NotAuthorizedError
|
|
8
7
|
from .. import Middleware, ViewSetResult
|
|
9
8
|
|
|
10
9
|
if TYPE_CHECKING:
|
|
@@ -23,14 +22,18 @@ class Authorization(Middleware):
|
|
|
23
22
|
- If it's callable, it's evaluated as `check(request, cls, context)` (sync or async) in
|
|
24
23
|
`depends()` - bridged onto FastAPI's own `Depends()` by route_viewset - a falsy result
|
|
25
24
|
rejects the request with 403 before `perform_*` (or even FastAPI's own body parsing) ever
|
|
26
|
-
runs. Use this for checks that don't need a specific record (e.g. a role check).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
runs. Use this for checks that don't need a specific record (e.g. a role check). A callable
|
|
26
|
+
config is consumed entirely by `depends()` and never reaches `context.authorization` - there
|
|
27
|
+
is nothing left for `perform_*` to do with the check function itself, and a bare function
|
|
28
|
+
isn't something a `celery_viewset` worker could receive anyway.
|
|
29
|
+
- Otherwise (non-callable, or unconfigured/`None`), nothing is enforced by the middleware
|
|
30
|
+
itself; the value is made available as `context.authorization` (set in `__call__`, in the
|
|
31
|
+
onion chain - there's no reject decision here, so there's no benefit to doing this any
|
|
32
|
+
earlier) - `perform_*` can inspect it once it has whatever record-specific info a check
|
|
33
|
+
needs (e.g. the object a `pk` resolved to) and reject itself (`raise HTTPException(403,
|
|
34
|
+
...)`) if it decides to. This is the "no hook in the middleware layer needed" case - just
|
|
35
|
+
raise, same as anywhere else in the pipeline (see docs/guide/action-configuration.md's
|
|
36
|
+
"Rejecting a request: just raise").
|
|
34
37
|
|
|
35
38
|
Unconfigured (no `@action_configuration` entry for `Authorization` at all): nothing is
|
|
36
39
|
enforced, `context.authorization` resolves to `None`.
|
|
@@ -54,7 +57,7 @@ class Authorization(Middleware):
|
|
|
54
57
|
if inspect.isawaitable(allowed):
|
|
55
58
|
allowed = await allowed
|
|
56
59
|
if not allowed:
|
|
57
|
-
raise
|
|
60
|
+
raise NotAuthorizedError()
|
|
58
61
|
|
|
59
62
|
async def __call__(
|
|
60
63
|
self,
|
|
@@ -63,5 +66,7 @@ class Authorization(Middleware):
|
|
|
63
66
|
context: "Context",
|
|
64
67
|
call_next: Callable[[], Awaitable[ViewSetResult]],
|
|
65
68
|
) -> ViewSetResult:
|
|
66
|
-
|
|
69
|
+
config = self.config_from(context)
|
|
70
|
+
if not callable(config):
|
|
71
|
+
context.set("authorization", config)
|
|
67
72
|
return await call_next()
|
|
@@ -4,8 +4,7 @@ import time
|
|
|
4
4
|
from collections.abc import Awaitable, Callable
|
|
5
5
|
from typing import Any, TYPE_CHECKING
|
|
6
6
|
|
|
7
|
-
from
|
|
8
|
-
|
|
7
|
+
from ..exceptions import RateLimitedError
|
|
9
8
|
from . import Middleware, ViewSetResult
|
|
10
9
|
|
|
11
10
|
if TYPE_CHECKING:
|
|
@@ -72,7 +71,7 @@ class RateLimiter(Middleware):
|
|
|
72
71
|
key = await key
|
|
73
72
|
allowed = await self._increment(key, limit)
|
|
74
73
|
if not allowed:
|
|
75
|
-
raise
|
|
74
|
+
raise RateLimitedError()
|
|
76
75
|
|
|
77
76
|
async def __call__(
|
|
78
77
|
self,
|
|
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
|
|
|
2
2
|
from enum import Enum
|
|
3
3
|
from typing import Annotated, Any, final, Generic, get_args, get_origin, Union
|
|
4
4
|
|
|
5
|
-
from fastapi import APIRouter, Header,
|
|
5
|
+
from fastapi import APIRouter, Header, Query
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, create_model
|
|
7
7
|
from pydantic.alias_generators import to_camel
|
|
8
8
|
from typing_extensions import TypeVar
|
|
@@ -21,6 +21,7 @@ from fastapi_viewsets.cursor import (
|
|
|
21
21
|
make_predicate,
|
|
22
22
|
position_of,
|
|
23
23
|
)
|
|
24
|
+
from fastapi_viewsets.exceptions import CursorRequestError, UnsupportedListShapeError
|
|
24
25
|
from fastapi_viewsets.filters import filters_from, FilterSet
|
|
25
26
|
from fastapi_viewsets.list_query import (
|
|
26
27
|
build_list_query,
|
|
@@ -315,10 +316,7 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
315
316
|
default, allowed = self.resolve_shapes()
|
|
316
317
|
shape = x_list_shape or default
|
|
317
318
|
if shape not in allowed:
|
|
318
|
-
raise
|
|
319
|
-
status_code=422,
|
|
320
|
-
detail=f"unsupported list shape {shape!r}; this endpoint offers {', '.join(allowed)}",
|
|
321
|
-
)
|
|
319
|
+
raise UnsupportedListShapeError(shape, list(allowed))
|
|
322
320
|
|
|
323
321
|
query = build_list_query(
|
|
324
322
|
fltr,
|
|
@@ -446,7 +444,7 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
446
444
|
except CursorError as error:
|
|
447
445
|
# The client sent something wrong, not the server - a stale cursor after a sort
|
|
448
446
|
# change is the ordinary case, and it deserves a message rather than a traceback.
|
|
449
|
-
raise
|
|
447
|
+
raise CursorRequestError(error) from None
|
|
450
448
|
query.extra_filters.append(make_predicate(state, keys, self.nulls_first))
|
|
451
449
|
|
|
452
450
|
backwards = bool(state and state.backwards)
|
|
@@ -37,8 +37,6 @@ consume the whole body before learning whether it was an error at all.
|
|
|
37
37
|
from typing import Any
|
|
38
38
|
from urllib.parse import urlencode
|
|
39
39
|
|
|
40
|
-
from muxws.frames import ABSENT
|
|
41
|
-
|
|
42
40
|
METHOD_KEY = ":method"
|
|
43
41
|
PATH_KEY = ":path"
|
|
44
42
|
QUERY_KEY = ":query"
|
|
@@ -120,4 +118,6 @@ def build_response_meta(status: int, headers: dict[str, str]) -> dict[str, Any]:
|
|
|
120
118
|
|
|
121
119
|
def body_to_payload(body: Any) -> Any:
|
|
122
120
|
"""`ABSENT` is muxws' "no payload at all", which is a different frame from a null payload."""
|
|
121
|
+
from muxws.frames import ABSENT
|
|
122
|
+
|
|
123
123
|
return ABSENT if body is None else body
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.6"
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
from fastapi import HTTPException
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class NotFoundResponse(BaseModel):
|
|
8
|
-
detail: str = "Item with pk {pk} not found"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
NOT_FOUND_RESPONSE = {"404": {"model": NotFoundResponse, "message": NotFoundResponse().detail}}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class NotFoundError(HTTPException):
|
|
15
|
-
def __init__(self, pk: Any):
|
|
16
|
-
# NotFoundResponse has only "detail", so it deconstructs into exactly the same parameter for HTTPException
|
|
17
|
-
super().__init__(status_code=404, **NotFoundResponse(detail=f"Item with pk {pk} not found").model_dump())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/conf.py
RENAMED
|
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
|