dynamicforms-fastapi-viewsets 0.15.1__tar.gz → 0.15.2__tar.gz

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