dynamicforms-fastapi-viewsets 0.11.4__tar.gz → 0.12.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.
Files changed (72) hide show
  1. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/PKG-INFO +1 -1
  2. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/__init__.py +5 -0
  3. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/cursor.py +10 -6
  4. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +7 -6
  5. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/server.py +9 -8
  6. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/route_viewset.py +4 -1
  7. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/error_namespace.py +57 -0
  8. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/error_responses.py +261 -0
  9. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/conftest.py +6 -0
  10. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/viewset.py +25 -22
  11. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/exceptions.py +95 -67
  12. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/idempotency.py +3 -2
  13. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/protocol.py +15 -5
  14. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/server.py +16 -2
  15. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/response_classes.py +113 -0
  16. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/routing.py +38 -3
  17. dynamicforms_fastapi_viewsets-0.11.4/fastapi_viewsets/__init__.py +0 -1
  18. dynamicforms_fastapi_viewsets-0.11.4/fastapi_viewsets/response_classes.py +0 -8
  19. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/.gitignore +0 -0
  20. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/LICENSE +0 -0
  21. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/README.md +0 -0
  22. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/action_configuration.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/__init__.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/django_orm.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/collection_viewset.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/conf.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/__init__.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/django.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/static.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/__init__.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/authorizers.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/changes.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/delivery.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/event.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/lifecycle.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/redis.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/scopes.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/sources.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/store.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/topics.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/__init__.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/base.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/declaration.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/operators.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/registry.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_query.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_shapes.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mixins.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/py.typed +0 -0
  69. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  71. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.11.4 → dynamicforms_fastapi_viewsets-0.12.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.11.4
3
+ Version: 0.12.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,5 @@
1
+ __version__ = "0.12.0"
2
+
3
+ from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
+
5
+ __all__ = ["ERROR_NAMESPACE", "ERROR_NAMESPACE_PYDANTIC", "__version__"]
@@ -34,6 +34,7 @@ from typing import Any, ClassVar, Generic, TYPE_CHECKING
34
34
  from pydantic import BaseModel, TypeAdapter
35
35
  from typing_extensions import TypeVar
36
36
 
37
+ from .error_namespace import _namespaced
37
38
  from .filters import Filter
38
39
 
39
40
  if TYPE_CHECKING:
@@ -43,7 +44,10 @@ TItem = TypeVar("TItem")
43
44
 
44
45
 
45
46
  class CursorError(ValueError):
46
- """The cursor is unreadable, or does not belong to the query it was sent with."""
47
+ """
48
+ The cursor is unreadable, or does not belong to the query it was sent with. `code` is one of the
49
+ cursor codes, `fastapi-viewsets.cursor_<reason>`.
50
+ """
47
51
 
48
52
  def __init__(self, message: str, code: str, params: dict[str, Any] | None = None):
49
53
  super().__init__(message)
@@ -135,16 +139,16 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
135
139
  payload = json.loads(base64.urlsafe_b64decode(padded))
136
140
  except (ValueError, binascii.Error) as error:
137
141
  raise CursorError(
138
- f"cursor is not readable: {error}", code="cursor_unreadable", params={"error": str(error)}
142
+ f"cursor is not readable: {error}", code=_namespaced("cursor_unreadable"), params={"error": str(error)}
139
143
  ) from None
140
144
 
141
145
  if not isinstance(payload, dict) or not isinstance(payload.get("p"), dict):
142
- raise CursorError("cursor is not readable: no position in it", code="cursor_missing_position")
146
+ raise CursorError("cursor is not readable: no position in it", code=_namespaced("cursor_missing_position"))
143
147
 
144
148
  if payload.get("q") != expected_query:
145
149
  raise CursorError(
146
150
  "this cursor was issued for a different ordering or filter - start from the first page",
147
- code="cursor_stale",
151
+ code=_namespaced("cursor_stale"),
148
152
  )
149
153
 
150
154
  position = payload["p"]
@@ -152,7 +156,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
152
156
  if missing:
153
157
  raise CursorError(
154
158
  f"cursor has no value for ordering key(s): {', '.join(missing)}",
155
- code="cursor_missing_keys",
159
+ code=_namespaced("cursor_missing_keys"),
156
160
  params={"missing": missing},
157
161
  )
158
162
 
@@ -168,7 +172,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
168
172
  except Exception as error:
169
173
  raise CursorError(
170
174
  f"cursor value for {name!r} does not fit the field: {error}",
171
- code="cursor_value_mismatch",
175
+ code=_namespaced("cursor_value_mismatch"),
172
176
  params={"name": name, "error": str(error)},
173
177
  ) from None
174
178
 
@@ -136,9 +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), 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`.
139
+ class), a `RequestValidationError` as a 422 with its errors as `detail` and `class`
140
+ `"RequestValidationError"`, any other error as `ActionFailedError`. `http_status_code` and
141
+ `http_detail` repeat the status and detail for a reader that does not decode `error`.
142
142
  """
143
143
  if not isinstance(error, (HTTPException, RequestValidationError)):
144
144
  error = ActionFailedError(task)
@@ -169,9 +169,10 @@ def push_result(
169
169
  """
170
170
  Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
171
171
  the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
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.
172
+ of a class `fastapi_viewsets.exceptions` defines keeps its class), and so does a
173
+ `RequestValidationError`; any other error reaches it as `ActionFailedError`. `task` names the
174
+ call's task in that error. The FastAPI process answers each as `fastapi_viewsets.error_responses`
175
+ answers it.
175
176
  """
176
177
  if error is not None:
177
178
  data = error_payload(correlation_id, task, error)
@@ -20,7 +20,6 @@ from functools import wraps
20
20
  from typing import Any, get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
21
21
 
22
22
  from fastapi import HTTPException
23
- from fastapi.encoders import jsonable_encoder
24
23
  from fastapi.exceptions import RequestValidationError
25
24
  from pydantic import BaseModel, TypeAdapter, ValidationError
26
25
 
@@ -666,14 +665,13 @@ def celery_viewset_server(
666
665
  _report("interrupted", task_name, call, lambda t: ActionInterruptedError(t, "time_limit"))
667
666
  raise pool_timeout
668
667
  error = run.translate(caught)
669
- if isinstance(error, RequestValidationError):
670
- # a request the worker found invalid (a filter value that will not convert) answers as
671
- # FastAPI answers one: 422 with the errors as `detail`
672
- error = HTTPException(status_code=422, detail=jsonable_encoder(error.errors()))
673
668
  process_exit = isinstance(error, (KeyboardInterrupt, SystemExit))
674
669
  if process_exit:
675
670
  pushed = ActionInterruptedError(task_name, "terminated")
676
- elif isinstance(error, HTTPException):
671
+ elif isinstance(error, (HTTPException, RequestValidationError)):
672
+ # a request the worker found invalid (a filter value that will not convert) is pushed as
673
+ # the `RequestValidationError` it is, and answered as a request the FastAPI process found
674
+ # invalid
677
675
  pushed = error
678
676
  else:
679
677
  pushed = ActionFailedError(task_name)
@@ -686,12 +684,15 @@ def celery_viewset_server(
686
684
  exc_info=caught,
687
685
  )
688
686
  else:
687
+ status, detail = (
688
+ (pushed.status_code, pushed.detail) if isinstance(pushed, HTTPException) else (422, pushed.errors())
689
+ )
689
690
  logger.info(
690
691
  "Celery task returned standard error: %s (correlation_id=%s): %d: %s",
691
692
  task_name,
692
693
  correlation_id,
693
- pushed.status_code,
694
- pushed.detail,
694
+ status,
695
+ detail,
695
696
  )
696
697
  if process_exit and not _supervised():
697
698
  _report("interrupted", task_name, call, lambda _t: pushed)
@@ -24,6 +24,7 @@ from fastapi_viewsets.endpoint_docs import (
24
24
  from fastapi_viewsets.middleware import any_modifies_response_shape, Middleware, unwrap_viewset_result_type
25
25
  from fastapi_viewsets.mixins import FilterParam
26
26
  from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
27
+ from fastapi_viewsets.response_classes import with_error_responses
27
28
  from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
28
29
 
29
30
  from .build_schema import _pydantic_generic_args, build_schema, route_to_add_api_route_kwargs
@@ -418,7 +419,9 @@ def route_viewset(
418
419
  documented.add(action_name)
419
420
  paths.setdefault(action_name, route.path[len(base) :] if route.path.startswith(base) else route.path)
420
421
  dependencies = list(route.dependencies or []) + [Depends(_make_middleware_depends(cls, action_name))]
421
- route_kwargs = route_to_add_api_route_kwargs(route, dependencies=dependencies)
422
+ route_kwargs = route_to_add_api_route_kwargs(
423
+ route, dependencies=dependencies, responses=with_error_responses(route.responses)
424
+ )
422
425
  # Per-viewset wording for an endpoint the mixin provided - the mixin's own docstring is
423
426
  # the same sentence on every viewset in the application. See endpoint_docs.py.
424
427
  route_kwargs.update(docs_for(cls, action_name))
@@ -0,0 +1,57 @@
1
+ """
2
+ The namespaces of the error codes this package's errors carry.
3
+
4
+ An error code is `<namespace>.<code>`: `code` is snake_case without `.`, `namespace` one or more
5
+ segments of `[a-z0-9]+(-[a-z0-9]+)*` joined by `.`, and a full code splits at its last `.`. The
6
+ codes this package declares are in `ERROR_NAMESPACE`. `ERROR_NAMESPACE_PYDANTIC` is the namespace
7
+ of pydantic's error types, which the field errors of a `fastapi-viewsets.validation_failed` body carry
8
+ as `pydantic.<type>`, and of `pydantic.custom_error`, the field error of a type pydantic does not
9
+ declare. A code the application gives an error is used as given.
10
+ """
11
+
12
+ ERROR_NAMESPACE = "fastapi-viewsets"
13
+ """The namespace of every error code this package declares, e.g. `fastapi-viewsets.not_found`."""
14
+
15
+ ERROR_NAMESPACE_PYDANTIC = "pydantic"
16
+ """The namespace of pydantic's error types, e.g. `pydantic.string_too_short`."""
17
+
18
+ _PREFIX = f"{ERROR_NAMESPACE}."
19
+
20
+ _DECLARED_CODES = frozenset(
21
+ {
22
+ "not_found",
23
+ "read_only",
24
+ "session_expired",
25
+ "not_authorized",
26
+ "rate_limited",
27
+ "result_timeout",
28
+ "result_reader_stopped",
29
+ "action_dispatch_failed",
30
+ "action_interrupted",
31
+ "action_failed",
32
+ "idempotency_key_invalid",
33
+ "idempotency_key_reused",
34
+ "unsupported_list_shape",
35
+ "event_topic_invalid",
36
+ "event_topic_refused",
37
+ "event_scope_missing",
38
+ "event_subscriptions_exceeded",
39
+ "event_cursors_exceeded",
40
+ "cursor_unreadable",
41
+ "cursor_missing_position",
42
+ "cursor_stale",
43
+ "cursor_missing_keys",
44
+ "cursor_value_mismatch",
45
+ "validation_failed",
46
+ "http_error",
47
+ "unhandled_error",
48
+ "command_invalid",
49
+ "pk_assigned_by_server",
50
+ }
51
+ )
52
+ """Every code this package declares, without the namespace; each is built with `_namespaced()`."""
53
+
54
+
55
+ def _namespaced(code: str) -> str:
56
+ """The full code of `code`, an error code this package declares."""
57
+ return f"{_PREFIX}{code}"
@@ -0,0 +1,261 @@
1
+ """
2
+ The body of every error response: `ApiErrorBody`.
3
+
4
+ {
5
+ "detail": "Item with pk 42 not found",
6
+ "detail_code": "fastapi-viewsets.not_found",
7
+ "detail_params": {"pk": 42},
8
+ "field_errors": {}
9
+ }
10
+
11
+ `error_body()` builds it from an exception:
12
+
13
+ - a `DfViewSetError`: its `detail`, `code` and `params`;
14
+ - a `RequestValidationError`: `fastapi-viewsets.validation_failed`, with its errors in
15
+ `field_errors`;
16
+ - any other `HTTPException` (FastAPI's, Starlette's, one an application raises, Starlette's own
17
+ 404 and 405 of the router): `fastapi-viewsets.http_error` with `params.status`;
18
+ - any other exception: `fastapi-viewsets.unhandled_error` (`UnhandledError`), status 500, with no
19
+ part of the exception in the body.
20
+
21
+ The routes of a viewset answer every error with this body without any registration (see
22
+ `routing.TransportRoute`). `install_error_handlers(app)` answers the errors of the application's
23
+ other routes, and of a request no route matches, with it too.
24
+ """
25
+
26
+ from http import HTTPStatus
27
+ from typing import Any, get_args
28
+
29
+ from fastapi import FastAPI
30
+ from fastapi.encoders import jsonable_encoder
31
+ from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler
32
+ from fastapi.exceptions import RequestValidationError
33
+ from fastapi.utils import is_body_allowed_for_status_code
34
+ from pydantic import BaseModel, Field
35
+ from pydantic_core.core_schema import ErrorType
36
+ from starlette.exceptions import HTTPException
37
+ from starlette.requests import Request
38
+ from starlette.responses import JSONResponse, Response
39
+
40
+ from .error_namespace import _namespaced, ERROR_NAMESPACE_PYDANTIC
41
+ from .exceptions import DfViewSetError, UnhandledError
42
+
43
+
44
+ class ApiFieldError(BaseModel):
45
+ """One failure of one value of a request that failed validation."""
46
+
47
+ code: str = Field(
48
+ description=(
49
+ "pydantic's error type in the namespace `pydantic`, e.g. `pydantic.string_too_short`; "
50
+ "`pydantic.custom_error` for a type pydantic does not declare (a `PydanticCustomError` of the "
51
+ "application); `fastapi-viewsets.pk_assigned_by_server` for a primary key in a create's body."
52
+ ),
53
+ examples=["pydantic.string_too_short"],
54
+ )
55
+ params: dict[str, Any] = Field(
56
+ description=(
57
+ "The values pydantic built `detail` from (pydantic's `ctx`); `{}` without any. For "
58
+ "`pydantic.custom_error`: `{type, ctx}`, the custom error's type and its `ctx`."
59
+ ),
60
+ examples=[{"min_length": 3}, {"type": "app.slug_taken", "ctx": {"slug": "blue"}}],
61
+ )
62
+ detail: str = Field(
63
+ description="pydantic's English message (`msg`).",
64
+ examples=["String should have at least 3 characters"],
65
+ )
66
+
67
+
68
+ class ApiErrorBody(BaseModel):
69
+ """The body of every error response."""
70
+
71
+ detail: str = Field(
72
+ description="English text of the error, interpolated from `detail_params`. Not translated.",
73
+ examples=["Item with pk 42 not found"],
74
+ )
75
+ detail_code: str = Field(
76
+ description=(
77
+ "The error's full code, `<namespace>.<code>`: `fastapi-viewsets.<code>` for the errors the "
78
+ "package raises, the application's own code as given for its own `DfViewSetError`."
79
+ ),
80
+ examples=["fastapi-viewsets.not_found"],
81
+ )
82
+ detail_params: dict[str, Any] = Field(
83
+ description="The values `detail` was interpolated with, by name; `{}` without any.",
84
+ examples=[{"pk": 42}],
85
+ )
86
+ field_errors: dict[str, list[ApiFieldError]] = Field(
87
+ description=(
88
+ "The failures of a request that failed validation (`fastapi-viewsets.validation_failed`), keyed "
89
+ "by field name: pydantic's `loc` without its first element (`body`, `query`, `path`, `header`, "
90
+ "`cookie`), joined with `.`; a failure of the body as a whole is keyed `''`. `{}` for every "
91
+ "other error."
92
+ ),
93
+ examples=[{}],
94
+ )
95
+
96
+
97
+ VALIDATION_FAILED_DETAIL = "The request failed validation"
98
+ """The `detail` of `fastapi-viewsets.validation_failed`."""
99
+
100
+
101
+ def _jsonable_ctx(ctx: dict[str, Any] | None) -> dict[str, Any]:
102
+ """pydantic's `ctx` as JSON values: an exception (the `error` of `value_error`) as its message."""
103
+ return jsonable_encoder(
104
+ {name: str(value) if isinstance(value, BaseException) else value for name, value in (ctx or {}).items()}
105
+ )
106
+
107
+
108
+ PYDANTIC_ERROR_TYPES = frozenset(get_args(ErrorType))
109
+ """The error types pydantic declares: the values of `pydantic_core.core_schema.ErrorType`."""
110
+
111
+ CUSTOM_ERROR_CODE = f"{ERROR_NAMESPACE_PYDANTIC}.custom_error"
112
+ """The code of a field error whose type pydantic does not declare."""
113
+
114
+ _PACKAGE_FIELD_ERROR_CODES = {"pk_assigned_by_server": _namespaced("pk_assigned_by_server")}
115
+ """The codes of the `PydanticCustomError` types this package raises, by type."""
116
+
117
+
118
+ def _field_error(error: Any) -> dict[str, Any]:
119
+ error_type = error.get("type")
120
+ ctx = _jsonable_ctx(error.get("ctx"))
121
+ if error_type in PYDANTIC_ERROR_TYPES:
122
+ code, params = f"{ERROR_NAMESPACE_PYDANTIC}.{error_type}", ctx
123
+ elif error_type in _PACKAGE_FIELD_ERROR_CODES:
124
+ code, params = _PACKAGE_FIELD_ERROR_CODES[error_type], ctx
125
+ else:
126
+ code, params = CUSTOM_ERROR_CODE, {"type": error_type, "ctx": ctx}
127
+ return {"code": code, "params": params, "detail": str(error.get("msg", ""))}
128
+
129
+
130
+ def field_errors(errors: Any) -> dict[str, list[dict[str, Any]]]:
131
+ """
132
+ pydantic's errors (`RequestValidationError.errors()`) as `ApiErrorBody.field_errors`: keyed by
133
+ `loc` without its first element, joined with `.` (`("body", "address", "city")` is
134
+ `address.city`, `("body",)` is `''`), each `{code, params, detail}` with `msg` as `detail`.
135
+ `input` is left out.
136
+
137
+ - A `type` pydantic declares (`PYDANTIC_ERROR_TYPES`) is `code` in the namespace
138
+ `ERROR_NAMESPACE_PYDANTIC` (`pydantic.string_too_short`), with `ctx` as `params`.
139
+ - `pk_assigned_by_server`, the type this package raises, is `fastapi-viewsets.pk_assigned_by_server`,
140
+ with `ctx` as `params`.
141
+ - Any other `type` (a `PydanticCustomError` of the application) is `pydantic.custom_error`, with
142
+ `params` `{"type": <type>, "ctx": <ctx>}`: `ctx` is nested, so none of its keys collides with
143
+ `type`.
144
+ """
145
+ grouped: dict[str, list[dict[str, Any]]] = {}
146
+ for error in errors or ():
147
+ loc = error.get("loc") or ()
148
+ name = ".".join(str(part) for part in list(loc)[1:])
149
+ grouped.setdefault(name, []).append(_field_error(error))
150
+ return grouped
151
+
152
+
153
+ def _body(detail: str, code: str, params: dict[str, Any], fields: dict | None = None) -> dict[str, Any]:
154
+ return {"detail": detail, "detail_code": code, "detail_params": params, "field_errors": fields or {}}
155
+
156
+
157
+ def error_status(error: BaseException) -> int:
158
+ """The status `error` is answered with: an `HTTPException`'s own, 422 for a
159
+ `RequestValidationError`, 500 for any other exception."""
160
+ if isinstance(error, HTTPException):
161
+ return error.status_code
162
+ if isinstance(error, RequestValidationError):
163
+ return 422
164
+ return 500
165
+
166
+
167
+ def error_body(error: BaseException) -> dict[str, Any]:
168
+ """
169
+ `error` as an `ApiErrorBody`, in JSON form.
170
+
171
+ - `DfViewSetError`: `detail`, `code` as `detail_code`, `params` as `detail_params`.
172
+ - `RequestValidationError`: `fastapi-viewsets.validation_failed`, `detail_params` `{}`, its errors
173
+ in `field_errors` (see `field_errors()`).
174
+ - another `HTTPException`: `fastapi-viewsets.http_error`, `detail_params` `{"status": <status>}`.
175
+ A string `detail` is the body's `detail`; any other `detail` is `detail_params.detail` and the
176
+ body's `detail` is the status phrase (`Bad Request`).
177
+ - any other exception: the body of `UnhandledError`.
178
+ """
179
+ if isinstance(error, RequestValidationError):
180
+ return _body(VALIDATION_FAILED_DETAIL, _namespaced("validation_failed"), {}, field_errors(error.errors()))
181
+ if not isinstance(error, HTTPException):
182
+ error = UnhandledError()
183
+ if isinstance(error, DfViewSetError):
184
+ return _body(jsonable_encoder(error.detail), error.code, jsonable_encoder(error.params))
185
+ params: dict[str, Any] = {"status": error.status_code}
186
+ detail = error.detail
187
+ if not isinstance(detail, str):
188
+ params["detail"] = jsonable_encoder(detail)
189
+ try:
190
+ detail = HTTPStatus(error.status_code).phrase
191
+ except ValueError:
192
+ detail = f"HTTP {error.status_code}"
193
+ return _body(detail, _namespaced("http_error"), params)
194
+
195
+
196
+ def error_response(error: BaseException) -> Response:
197
+ """
198
+ The response that answers `error`: `error_status(error)`, `error_body(error)` as JSON, and an
199
+ `HTTPException`'s `headers`. A status that allows no body (204, 304) is answered without one.
200
+ """
201
+ status = error_status(error)
202
+ headers = getattr(error, "headers", None) if isinstance(error, HTTPException) else None
203
+ if not is_body_allowed_for_status_code(status):
204
+ return Response(status_code=status, headers=headers)
205
+ return JSONResponse(error_body(error), status_code=status, headers=headers)
206
+
207
+
208
+ async def _handle_error(_request: Request, error: Exception) -> Response:
209
+ """The handler `install_error_handlers` registers, and the one a viewset route answers
210
+ `HTTPException` and `RequestValidationError` with. For `Exception` Starlette's
211
+ `ServerErrorMiddleware` re-raises the exception after the response, and the server logs it."""
212
+ return error_response(error)
213
+
214
+
215
+ def install_error_handlers(app: FastAPI) -> None:
216
+ """
217
+ Registers the handlers that answer the errors of every route of `app` with an `ApiErrorBody`
218
+ (see `error_body()`), for `DfViewSetError`, `HTTPException` (Starlette's, which FastAPI's
219
+ subclasses), `RequestValidationError` and `Exception`. Starlette's own 404 of a path no route
220
+ matches and 405 of a method a route does not serve are `fastapi-viewsets.http_error` with
221
+ `params.status` 404 and 405. An exception without a handler of its own is answered with 500
222
+ `fastapi-viewsets.unhandled_error` and re-raised by Starlette, so the server logs it.
223
+
224
+ A handler the application registers for one of these classes afterwards replaces this one; a
225
+ handler for a more specific class, or for a status, takes precedence as Starlette's lookup
226
+ gives it.
227
+
228
+ The routes of a viewset answer with an `ApiErrorBody` whether or not this is called.
229
+ """
230
+ app.add_exception_handler(DfViewSetError, _handle_error)
231
+ app.add_exception_handler(HTTPException, _handle_error)
232
+ app.add_exception_handler(RequestValidationError, _handle_error)
233
+ app.add_exception_handler(Exception, _handle_error)
234
+
235
+
236
+ _FASTAPI_HANDLERS = (http_exception_handler, request_validation_exception_handler)
237
+ """The handlers a FastAPI application registers by default."""
238
+
239
+
240
+ EXCEPTION_HANDLERS_SCOPE_KEY = "starlette.exception_handlers"
241
+
242
+
243
+ def uniform_exception_handlers(scope: dict[str, Any]) -> None:
244
+ """
245
+ Replaces, for the request of `scope`, FastAPI's default handlers of `HTTPException` and
246
+ `RequestValidationError` with one that answers an `ApiErrorBody`. Handlers the application
247
+ registered for a class or a status are kept.
248
+ """
249
+ exception_handlers, status_handlers = scope.get(EXCEPTION_HANDLERS_SCOPE_KEY) or ({}, {})
250
+ handlers = {
251
+ key: (_handle_error if handler in _FASTAPI_HANDLERS else handler) for key, handler in exception_handlers.items()
252
+ }
253
+ handlers.setdefault(HTTPException, _handle_error)
254
+ handlers.setdefault(RequestValidationError, _handle_error)
255
+ scope[EXCEPTION_HANDLERS_SCOPE_KEY] = (handlers, status_handlers)
256
+
257
+
258
+ def answers_unhandled_itself(app: Any) -> bool:
259
+ """Whether `app` has a handler of its own, not `install_error_handlers`', for `Exception` or 500."""
260
+ handlers = getattr(app, "exception_handlers", None) or {}
261
+ return any(key in handlers and handlers[key] is not _handle_error for key in (Exception, 500))
@@ -9,6 +9,7 @@ from typing import Any
9
9
  import pytest
10
10
 
11
11
  from ..conf import settings
12
+ from ..error_namespace import ERROR_NAMESPACE
12
13
  from . import store as store_module
13
14
  from .delivery import reset_subscriptions
14
15
  from .redis import _APPEND_SCRIPT
@@ -257,3 +258,8 @@ class FakePubSub:
257
258
  async def aclose(self) -> None:
258
259
  await self.unsubscribe(*list(self.subscribed))
259
260
  self.closed = True
261
+
262
+
263
+ def envelope(detail: str, code: str, **params: Any) -> dict[str, Any]:
264
+ """The `ApiErrorBody` of the package's error `code` (without the namespace)."""
265
+ return {"detail": detail, "detail_code": f"{ERROR_NAMESPACE}.{code}", "detail_params": params, "field_errors": {}}
@@ -23,6 +23,7 @@ from starlette.exceptions import HTTPException
23
23
  from ..conf import settings
24
24
  from ..context import Context
25
25
  from ..decorators.route_viewset import route_viewset
26
+ from ..error_responses import ApiErrorBody, error_body
26
27
  from ..exceptions import EventCursorsExceededError, EventTopicInvalidError
27
28
  from ..mux_ws.transports import transports
28
29
  from ..routing import PEER_SCOPE_KEY
@@ -91,13 +92,14 @@ class TopicAnswer(BaseModel):
91
92
  """
92
93
  The answer of `poll` for one topic. `events` is None when the log cannot answer the cursor; the
93
94
  caller then re-reads the state it displays. A topic that was refused, or that has neither form,
94
- has `rev` and `events` None, `error` set to the code of the refusal and `status` to its HTTP
95
- status. `error`, `status` and `scope` are left out of the serialized answer when they are None.
95
+ has `rev` and `events` None, `error` set to the `ApiErrorBody` of the refusal, as a call refused
96
+ with it is answered, and `status` to its HTTP status. `error`, `status` and `scope` are left out
97
+ of the serialized answer when they are None.
96
98
  """
97
99
 
98
100
  rev: int | None
99
101
  events: list[PolledEvent] | None
100
- error: str | None = None
102
+ error: ApiErrorBody | None = None
101
103
  status: int | None = None
102
104
  """The HTTP status of the refusal; set on every answer with `error`."""
103
105
  scope: str | None = None
@@ -146,11 +148,6 @@ def _reauthorizer(request: Request, topic: str) -> Callable[[], Awaitable[str]]:
146
148
  return reauthorize
147
149
 
148
150
 
149
- def _error_code(error: HTTPException) -> str:
150
- code = getattr(error, "code", None)
151
- return code if isinstance(code, str) and code else "event_topic_refused"
152
-
153
-
154
151
  class EventsViewSet:
155
152
  """
156
153
  Subscriptions to event topics over a muxws socket, and polls of event topics over either
@@ -166,11 +163,12 @@ class EventsViewSet:
166
163
  Subscribes the socket the command arrived on to `topic` and answers the current rev of its
167
164
  store topic, with the scope key for a scoped viewset topic (see
168
165
  `fastapi_viewsets.events.scopes`). Every event published to the store topic after it is
169
- pushed to the socket until the socket closes or unsubscribes. 422 `event_topic_invalid` for
170
- a topic of neither form, 403 `event_topic_refused`, or the status of the authorizer's
171
- `HTTPException`, for a topic the authorizers do not admit, 403 `event_scope_missing` for a
172
- scoped topic whose resolver answers None, 409 `event_subscriptions_exceeded` for a socket
173
- that already holds `settings.viewsets_event_max_subscriptions` topics. A subscribe refused
166
+ pushed to the socket until the socket closes or unsubscribes. 422
167
+ `fastapi-viewsets.event_topic_invalid` for a topic of neither form, 403
168
+ `fastapi-viewsets.event_topic_refused`, or the status of the authorizer's `HTTPException`, for
169
+ a topic the authorizers do not admit, 403 `fastapi-viewsets.event_scope_missing` for a scoped
170
+ topic whose resolver answers None, 409 `fastapi-viewsets.event_subscriptions_exceeded` for a
171
+ socket that already holds `settings.viewsets_event_max_subscriptions` topics. A subscribe refused
174
172
  with a status from 400 to 499 other than 429 removes the socket's subscription to the topic,
175
173
  if it holds one; any other error, a 429 or 5xx included, leaves it as it is. A command served by
176
174
  `process_command` without `peer=` raises `RuntimeError`.
@@ -208,18 +206,20 @@ class EventsViewSet:
208
206
  """
209
207
  Answers every cursor of the request. Per topic the authorizers run, then the store topic is
210
208
  resolved (see `fastapi_viewsets.events.scopes`); a refused topic answers `error` with the
211
- refusal's code (`event_topic_refused`, `event_scope_missing`, or the `code` of the
212
- `HTTPException` an authorizer raised, FastAPI's or Starlette's, `event_topic_refused` for one
213
- without a `code`) and `status` with its HTTP status, and `event_topic_invalid` with 422 for a
214
- topic of neither form, and the other topics are unaffected. A poll over muxws that refuses a
215
- topic with a status from 400 to 499 other than 429 removes the socket's subscription to it;
209
+ `ApiErrorBody` of the refusal (`fastapi-viewsets.event_topic_refused`,
210
+ `fastapi-viewsets.event_scope_missing`, or the body of the `HTTPException` an authorizer raised,
211
+ FastAPI's or Starlette's: its own code for a `DfViewSetError`, `fastapi-viewsets.http_error`
212
+ for any other) and `status` with its HTTP status, and the body of
213
+ `fastapi-viewsets.event_topic_invalid` with 422 for a topic of neither form, and the other
214
+ topics are unaffected. A poll over muxws that refuses a topic with a status from 400
215
+ to 499 other than 429 removes the socket's subscription to it;
216
216
  a 429 or a status of 500 or above keeps it. A None cursor answers the topic's current rev
217
217
  and no events, a cursor equal to it `[]`, and any other cursor the events since it, or
218
218
  `events: null` when the log holds no event whose `prev` is the cursor. When the request
219
219
  carries `scopes`, a cursor of a topic whose entry in `scopes` (absent for none) differs from
220
220
  the scope key the server resolves answers the current rev and `events: null`. Every answer of
221
- a scoped topic carries its `scope`. 422 `event_cursors_exceeded` for more cursors than
222
- `settings.viewsets_event_max_subscriptions`.
221
+ a scoped topic carries its `scope`. 422 `fastapi-viewsets.event_cursors_exceeded` for more
222
+ cursors than `settings.viewsets_event_max_subscriptions`.
223
223
 
224
224
  The delivery filters run on every event since the cursor, with a `Subscription` of the
225
225
  topic whose `peer` is the socket the poll arrived on, None over REST, and whose `context` is
@@ -237,13 +237,16 @@ class EventsViewSet:
237
237
  admitted: list[ResolvedTopic] = []
238
238
  for topic in cursors:
239
239
  if not is_valid_topic(topic):
240
- answers[topic] = TopicAnswer(rev=None, events=None, error="event_topic_invalid", status=422)
240
+ invalid = EventTopicInvalidError(topic)
241
+ answers[topic] = TopicAnswer(
242
+ rev=None, events=None, error=error_body(invalid), status=invalid.status_code
243
+ )
241
244
  continue
242
245
  try:
243
246
  await authorize_topic(request, context, topic)
244
247
  resolved = await resolve_topic(request, topic)
245
248
  except HTTPException as error:
246
- answers[topic] = TopicAnswer(rev=None, events=None, error=_error_code(error), status=error.status_code)
249
+ answers[topic] = TopicAnswer(rev=None, events=None, error=error_body(error), status=error.status_code)
247
250
  if peer is not None and delivery._is_refusal(error):
248
251
  await delivery.unsubscribe(peer, topic)
249
252
  continue