django-spec-html 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,34 @@
1
+ """A django-service-specs spec as a server-rendered page, plus the plain-Django JSON views.
2
+
3
+ ``views`` serves a spec as the page its form is, and ``http`` serves one as
4
+ JSON with nothing between the request and the kernel's ``dispatch``. Every
5
+ public name of both is here; read them from the root.
6
+ """
7
+
8
+ from django_spec_html.http.add_argument_errors import add_argument_errors
9
+ from django_spec_html.http.adispatch_request import adispatch_request
10
+ from django_spec_html.http.async_spec_view import AsyncSpecView
11
+ from django_spec_html.http.dispatch_request import dispatch_request
12
+ from django_spec_html.http.error_response import error_response
13
+ from django_spec_html.http.request_arguments import request_arguments
14
+ from django_spec_html.http.request_principal import request_principal
15
+ from django_spec_html.http.route_arguments import route_arguments
16
+ from django_spec_html.http.spec_view import SpecView
17
+ from django_spec_html.http.unsupported_media_type import UnsupportedMediaType
18
+ from django_spec_html.version import __version__
19
+ from django_spec_html.views.spec_form_view import SpecFormView
20
+
21
+ __all__ = [
22
+ "AsyncSpecView",
23
+ "SpecFormView",
24
+ "SpecView",
25
+ "UnsupportedMediaType",
26
+ "__version__",
27
+ "add_argument_errors",
28
+ "adispatch_request",
29
+ "dispatch_request",
30
+ "error_response",
31
+ "request_arguments",
32
+ "request_principal",
33
+ "route_arguments",
34
+ ]
@@ -0,0 +1,25 @@
1
+ """Serving an operation from a Django view, with nothing between the request and dispatch."""
2
+
3
+ from django_spec_html.http.add_argument_errors import add_argument_errors
4
+ from django_spec_html.http.adispatch_request import adispatch_request
5
+ from django_spec_html.http.async_spec_view import AsyncSpecView
6
+ from django_spec_html.http.dispatch_request import dispatch_request
7
+ from django_spec_html.http.error_response import error_response
8
+ from django_spec_html.http.request_arguments import request_arguments
9
+ from django_spec_html.http.request_principal import request_principal
10
+ from django_spec_html.http.route_arguments import route_arguments
11
+ from django_spec_html.http.spec_view import SpecView
12
+ from django_spec_html.http.unsupported_media_type import UnsupportedMediaType
13
+
14
+ __all__ = [
15
+ "AsyncSpecView",
16
+ "SpecView",
17
+ "UnsupportedMediaType",
18
+ "add_argument_errors",
19
+ "adispatch_request",
20
+ "dispatch_request",
21
+ "error_response",
22
+ "request_arguments",
23
+ "request_principal",
24
+ "route_arguments",
25
+ ]
@@ -0,0 +1,80 @@
1
+ """``add_argument_errors`` - a refusal tree placed on a bound form, beside the form's own errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from django.core.exceptions import NON_FIELD_ERRORS as FORM_NON_FIELD_ERRORS
9
+ from django.forms import BaseForm
10
+ from django_service_specs import NON_FIELD_KEYS, clean_held_to_their_columns, refusal_messages
11
+
12
+
13
+ def add_argument_errors(form: BaseForm, detail: Mapping[Any, Any]) -> None:
14
+ """Place a refusal tree on a bound ``form``, beside the form's own errors, never twice.
15
+
16
+ ``detail`` is an
17
+ [`InvalidArguments`][django_service_specs.parameters.invalid_arguments.InvalidArguments]
18
+ tree, or a ``ServiceValidationError``'s mapping. Each message goes where a
19
+ person reading the form looks for it, through Django's public
20
+ ``form.add_error``:
21
+
22
+ - **A key naming one of the form's fields** puts its messages on that
23
+ field. A form is flat, so the only tree below a field is an array's
24
+ element indices, which mean nothing to a person reading one control: the
25
+ messages go on the field without them.
26
+ - ``non_field_errors``, and Django's own ``"__all__"``, go to the form's
27
+ non-field errors as they are.
28
+ - **Any other key** - a URL kwarg such as ``pk``, or a key a service chose -
29
+ goes to the non-field errors prefixed with its path, ``"pk: ..."``, so the
30
+ reader knows what it is about. A nested tree is flattened to its leaves
31
+ with the path joined by dots, ``"books.1.title: ..."``, a row's own
32
+ ``non_field_errors`` adding nothing to the path.
33
+
34
+ **Never a duplicate.** A message the form already carries at the same place
35
+ is not added again. The form validating the same data says the same thing
36
+ the kernel does - a ``FormValidator``'s refusal *is* the form's errors, and
37
+ the shape check spells its messages as Django's fields do - so without this
38
+ most refusals would show twice. A form not yet validated is validated
39
+ first, as reading ``form.errors`` would, so the form's own errors are
40
+ always the ones compared against; but with the guards a
41
+ ``FormValidator``'s form is validated with, since the form's own
42
+ validation would hand the database driver a number no integer column
43
+ holds, which SQLite's refuses with ``OverflowError``. A model choice
44
+ field's key past its column's range is refused as ``invalid_choice`` and
45
+ a ``ModelForm``'s value past its integer column's range as ``max_value``
46
+ or ``min_value``, in the words a ``FormValidator`` refuses them with, so
47
+ the refusal of one is placed once
48
+ (test_a_list_s_key_past_its_column_is_refused_once,
49
+ test_a_model_form_s_integer_past_its_column_is_refused_once). A form
50
+ already validated is compared as it stands.
51
+
52
+ A leaf is a message whatever its type: a string, a lazy translation (which
53
+ is not a ``str``, and is never taken apart into characters), or a list of
54
+ either. The form must be bound, as Django's ``add_error`` requires.
55
+ """
56
+ # Django's own marker of a form not yet validated, which the stubs declare
57
+ # on no form, hence ``Any``. Each way is held by its own test:
58
+ # test_a_list_s_key_past_its_column_is_refused_once (not yet validated,
59
+ # so validated here with the guards) and
60
+ # test_a_form_already_validated_is_compared_as_it_stands (an error state
61
+ # the caller already holds, which validating here would start over).
62
+ bound: Any = form
63
+ if bound._errors is None:
64
+ clean_held_to_their_columns(form)
65
+ for key, value in detail.items():
66
+ field = key if key in form.fields else None
67
+ for path, message in refusal_messages(value):
68
+ if field is not None:
69
+ _add_once(form, field, message)
70
+ continue
71
+ where = path if key in NON_FIELD_KEYS else (str(key), *path)
72
+ _add_once(form, None, f"{'.'.join(where)}: {message}" if where else message)
73
+
74
+
75
+ def _add_once(form: BaseForm, field: str | None, message: str) -> None:
76
+ """``form.add_error``, unless the place already carries ``message``."""
77
+ # An ErrorList yields its messages as strings, so ``in`` compares a string
78
+ # with each, and a missing key reads as a place carrying nothing yet.
79
+ if message not in form.errors.get(FORM_NON_FIELD_ERRORS if field is None else field, ()):
80
+ form.add_error(field, message)
@@ -0,0 +1,97 @@
1
+ """``adispatch_request`` - ``dispatch_request``, from an async view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from asgiref.sync import sync_to_async
9
+ from django.http import HttpRequest, HttpResponse
10
+ from django_service_specs import (
11
+ DEFAULT_POOL_SEEDS,
12
+ DispatchError,
13
+ Grant,
14
+ PoolSeeds,
15
+ SelectorSpec,
16
+ ServiceError,
17
+ ServiceSpec,
18
+ SpecSource,
19
+ UnknownArguments,
20
+ adispatch,
21
+ apresent,
22
+ kernel_spec,
23
+ )
24
+
25
+ from django_spec_html.http.error_response import error_response
26
+ from django_spec_html.http.request_arguments import request_arguments
27
+ from django_spec_html.http.request_principal import request_principal
28
+ from django_spec_html.http.utils import (
29
+ check_asked_input,
30
+ not_found_response,
31
+ success_response,
32
+ )
33
+
34
+
35
+ async def adispatch_request(
36
+ spec: ServiceSpec | SelectorSpec | SpecSource,
37
+ request: HttpRequest,
38
+ *,
39
+ url_kwargs: Mapping[str, Any] | None = None,
40
+ success_status: int | None = None,
41
+ grant: Grant | None = None,
42
+ pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
43
+ unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
44
+ ) -> HttpResponse:
45
+ """[`dispatch_request`][django_spec_html.http.dispatch_request.dispatch_request], from async code.
46
+
47
+ The same principal, arguments, statuses and refusals, reached through
48
+ [`adispatch`][django_service_specs.dispatch.adispatch.adispatch] and
49
+ [`apresent`][django_service_specs.dispatch.apresent.apresent], so the
50
+ kernel's async rule holds: nothing that may query runs on the event loop.
51
+
52
+ That includes **reading the request**, which is one executor hop of its
53
+ own before ``adispatch``'s. ``request.user`` is the lazy object
54
+ AuthenticationMiddleware leaves, and its first read is a session query,
55
+ which Django refuses on the loop; Django 4.2, the floor, has no
56
+ ``request.auser()`` to await instead. ``spec.parameters()`` goes in the
57
+ same hop, because a Validator may read a model to declare what it takes.
58
+ Building the response does not query and stays on the loop.
59
+ """
60
+ # Compiled once, before anything is read, so the arguments, the answer's
61
+ # status and the asked-input check all read the one compiled spec.
62
+ spec = kernel_spec(spec)
63
+ try:
64
+ principal, arguments = await sync_to_async(_read_request, thread_sensitive=True)(
65
+ spec, request, url_kwargs
66
+ )
67
+ result = await adispatch(
68
+ spec,
69
+ principal=principal,
70
+ arguments=arguments,
71
+ grant=grant,
72
+ pool_seeds=pool_seeds,
73
+ unknown_arguments=unknown_arguments,
74
+ )
75
+ if result.kind == "not_found":
76
+ return not_found_response()
77
+ return success_response(spec, await apresent(spec, result), success_status)
78
+ except (DispatchError, ServiceError) as refused:
79
+ check_asked_input(spec, refused)
80
+ return error_response(refused)
81
+
82
+
83
+ def _read_request(
84
+ spec: ServiceSpec | SelectorSpec,
85
+ request: HttpRequest,
86
+ url_kwargs: Mapping[str, Any] | None,
87
+ ) -> tuple[Any, dict[str, Any]]:
88
+ """The principal, then the arguments, in the one executor hop they share.
89
+
90
+ The principal first, as ``adispatch`` resolves a ``principal_id`` before
91
+ its shape check, so a deactivated account is refused without learning
92
+ anything about its request. Both off the loop: the principal may be a
93
+ session query, and ``spec.parameters()`` may be one too, since a
94
+ Validator may read a model to declare what it takes.
95
+ """
96
+ principal = request_principal(request)
97
+ return principal, request_arguments(request, spec.parameters(), url_kwargs=url_kwargs)
@@ -0,0 +1,40 @@
1
+ """``AsyncSpecView`` - ``SpecView``, served from async code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django.http import HttpRequest, HttpResponse
8
+
9
+ from django_spec_html.http.adispatch_request import adispatch_request
10
+ from django_spec_html.http.utils import SpecViewBase
11
+
12
+
13
+ class AsyncSpecView(SpecViewBase):
14
+ """[`SpecView`][django_spec_html.http.spec_view.SpecView], with every handler async.
15
+
16
+ The same attributes, methods, checks and answers, through
17
+ [`adispatch_request`][django_spec_html.http.adispatch_request.adispatch_request].
18
+ Django serves a class-based view async only when every handler it defines
19
+ is ``async def``, and refuses a class that mixes the two, so every method
20
+ a spec may be served on is async here, not only the ones it answers by
21
+ default. Under ASGI this keeps the request off a worker thread until the
22
+ kernel needs one; under WSGI Django runs it in an event loop of its own,
23
+ which costs more than ``SpecView`` and buys nothing.
24
+
25
+ Not a subclass of ``SpecView``, for the same reason: an async handler
26
+ cannot stand in for a sync one.
27
+ """
28
+
29
+ async def answer(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
30
+ """Dispatch the spec from async code, the route's kwargs as ``url_kwargs``."""
31
+ return await adispatch_request(
32
+ self.served_spec(),
33
+ request,
34
+ url_kwargs=kwargs,
35
+ success_status=self.success_status,
36
+ pool_seeds=self.pool_seeds,
37
+ unknown_arguments=self.unknown_arguments,
38
+ )
39
+
40
+ get = head = post = put = patch = delete = answer
@@ -0,0 +1,98 @@
1
+ """``dispatch_request`` - one request, dispatched and answered, from a hand-written view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from django.http import HttpRequest, HttpResponse
9
+ from django_service_specs import (
10
+ DEFAULT_POOL_SEEDS,
11
+ DispatchError,
12
+ Grant,
13
+ PoolSeeds,
14
+ SelectorSpec,
15
+ ServiceError,
16
+ ServiceSpec,
17
+ SpecSource,
18
+ UnknownArguments,
19
+ dispatch,
20
+ kernel_spec,
21
+ present,
22
+ )
23
+
24
+ from django_spec_html.http.error_response import error_response
25
+ from django_spec_html.http.request_arguments import request_arguments
26
+ from django_spec_html.http.request_principal import request_principal
27
+ from django_spec_html.http.utils import (
28
+ check_asked_input,
29
+ not_found_response,
30
+ success_response,
31
+ )
32
+
33
+
34
+ def dispatch_request(
35
+ spec: ServiceSpec | SelectorSpec | SpecSource,
36
+ request: HttpRequest,
37
+ *,
38
+ url_kwargs: Mapping[str, Any] | None = None,
39
+ success_status: int | None = None,
40
+ grant: Grant | None = None,
41
+ pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
42
+ unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
43
+ ) -> HttpResponse:
44
+ """Dispatch ``spec`` for ``request`` and answer it as JSON: the function a view calls.
45
+
46
+ 1. **The principal is** ``request.user``, anonymous included, since the
47
+ spec's permission check is what decides whether anonymous may act. An
48
+ authenticated user whose ``is_active`` is false is refused as
49
+ ``PrincipalUnavailable``: a backend other than ``ModelBackend`` can log
50
+ one in, and a deactivated principal never acts. Dispatch refuses one by
51
+ the same rule; refusing here as well keeps the request unread for it.
52
+ 2. **The arguments** are
53
+ [`request_arguments`][django_spec_html.http.request_arguments.request_arguments]
54
+ for ``spec.parameters()``, with ``url_kwargs`` merged last.
55
+ 3. [`dispatch`][django_service_specs.dispatch.dispatch.dispatch], with
56
+ ``grant``, ``pool_seeds`` and ``unknown_arguments`` as given.
57
+ 4. **The answer.** A not-found result is 404 with a ``detail``. Otherwise
58
+ the presented value, bare, at ``success_status`` if given, else 204 for
59
+ a service with nothing to present and 200 for everything else - the
60
+ defaults djangorestframework-services answers with. A 204 has no body.
61
+
62
+ A refusal of either family, from any of the four steps, is answered by
63
+ [`error_response`][django_spec_html.http.error_response.error_response].
64
+ Anything else propagates, a configuration error included: it is wrong for
65
+ every caller, so it belongs to the host's error handling rather than to one
66
+ client's response. A URL kwarg ``spec`` does not declare is one, raised
67
+ as ``ImproperlyConfigured``: every URL kwarg is an argument, and the route
68
+ is the host's.
69
+
70
+ ``spec`` may be a
71
+ [`SpecSource`][django_service_specs.specs.spec_source.SpecSource]: it is
72
+ compiled once, first, and every step reads the spec it compiles to.
73
+
74
+ **CSRF is the host's middleware**, as for any Django view: nothing here
75
+ exempts a view, and ``csrf_exempt`` is one line for a host that wants it.
76
+ """
77
+ spec = kernel_spec(spec)
78
+ try:
79
+ # The principal first, as ``adispatch`` resolves a ``principal_id``
80
+ # and ``dispatch`` refuses a deactivated principal before its shape
81
+ # check: a deactivated account is refused without learning anything
82
+ # about its request.
83
+ principal = request_principal(request)
84
+ arguments = request_arguments(request, spec.parameters(), url_kwargs=url_kwargs)
85
+ result = dispatch(
86
+ spec,
87
+ principal=principal,
88
+ arguments=arguments,
89
+ grant=grant,
90
+ pool_seeds=pool_seeds,
91
+ unknown_arguments=unknown_arguments,
92
+ )
93
+ if result.kind == "not_found":
94
+ return not_found_response()
95
+ return success_response(spec, present(spec, result), success_status)
96
+ except (DispatchError, ServiceError) as refused:
97
+ check_asked_input(spec, refused)
98
+ return error_response(refused)
@@ -0,0 +1,104 @@
1
+ """``error_response`` - a refusal of either family, as the JSON an HTTP client reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from django.http import JsonResponse
9
+ from django_service_specs import (
10
+ ActionUnavailable,
11
+ AdditionalInputRequired,
12
+ DispatchError,
13
+ InvalidArguments,
14
+ ServiceError,
15
+ ServiceValidationError,
16
+ )
17
+
18
+ from django_spec_html.http.utils import field_map, refusal_status
19
+
20
+
21
+ def error_response(exc: DispatchError | ServiceError) -> JsonResponse:
22
+ """``exc`` as JSON, at the status djangorestframework-services answers it with.
23
+
24
+ One map for both families, at djangorestframework-services' statuses, and
25
+ the one the page reads too.
26
+ Two bodies differ from its answer. A service's string or list detail, which
27
+ DRF answers as a bare list and this as a field map under
28
+ ``non_field_errors``, so every 400 here has one shape. And an
29
+ ``AdditionalInputRequired`` schema, which DRF reads as an error detail and
30
+ so writes with every value a string (``"minimum": "1"``), and this writes as
31
+ the JSON Schema it is:
32
+
33
+ | Refusal | Status | Body |
34
+ | --- | --- | --- |
35
+ | `InvalidArguments` | 400 | the error tree |
36
+ | `ServiceValidationError` | 400 | its detail, as a field map |
37
+ | `NotPermitted`, `PrincipalUnavailable` | 403 | `{"detail": message}` |
38
+ | `ServiceNotFound` | 404 | `{"detail": message}` |
39
+ | `ActionUnavailable` | 409 | `{"detail": message, "code": code}` |
40
+ | any other `ServiceConflict` | 409 | `{"detail": message}` |
41
+ | `AdditionalInputRequired` with a `schema` | 422 | `{"detail": message, "schema": schema}` |
42
+ | any other `ServiceError` | 422 | `{"detail": message}` |
43
+ | `UnsupportedMediaType` | 415 | `{"detail": message}` |
44
+ | any other `DispatchError` | 400 | `{"detail": message}` |
45
+
46
+ **Every 400 body is a field map.** ``InvalidArguments`` is its tree, whose
47
+ integer row keys JSON writes as strings. A ``ServiceValidationError``'s
48
+ mapping is its body as it is, and a string or a list goes under
49
+ ``non_field_errors``, where a message about the input as a whole sits in
50
+ the tree too. A lazy translation is one message, not a sequence of
51
+ characters, and renders in the active language: ``JsonResponse``'s
52
+ encoder renders every lazy string it meets, messages included.
53
+
54
+ **Never 401.** A session-authenticated view has no challenge to answer,
55
+ and a 401 tells a client to start an authentication flow it has no way to
56
+ finish. An anonymous caller a permission check refuses is a 403, like any
57
+ other refused principal.
58
+
59
+ **Two members add a key beside ``detail``, and keep their base's status.**
60
+ ``ActionUnavailable`` is a conflict, and its arm exists only to put the
61
+ affordance's ``code`` in the body beside the ``detail`` a client already
62
+ reads: the stable code is the whole point of the member, and a client left
63
+ with only the sentence has nothing to branch on. ``AdditionalInputRequired``
64
+ is "I need one more value", which is the operation being unprocessable as
65
+ asked, so it stays a 422 like any other service error; the ``schema`` naming
66
+ what is missing joins the body, because without it a client is told that
67
+ something is needed and not what. Only when there is a schema: the error is
68
+ valid without one, and growing the body unconditionally would change every
69
+ plain message into an object for no gain.
70
+
71
+ A ``DispatchError`` no row names - raised by nobody in this package, or a
72
+ subclass a later release adds - is still a refusal of the call rather than
73
+ the operation's own verdict, so it answers 400 rather than 422.
74
+ """
75
+ status = refusal_status(exc)
76
+ if isinstance(exc, InvalidArguments):
77
+ return JsonResponse(exc.detail, status=status)
78
+ if isinstance(exc, ServiceValidationError):
79
+ return JsonResponse(field_map(exc.detail), status=status)
80
+ if isinstance(exc, ActionUnavailable):
81
+ return JsonResponse({"detail": exc.message, "code": exc.code}, status=status)
82
+ # Two conjuncts, each held in tests/http/test_error_response.py:
83
+ # ``test_without_a_schema_the_body_is_the_plain_detail`` fails without the
84
+ # second (a missing schema would be written as null), and the ``Postponed``
85
+ # row of ``test_a_message_refusal_is_its_status_and_a_detail`` without the
86
+ # first (a plain service error has no schema to read).
87
+ if isinstance(exc, AdditionalInputRequired) and exc.schema is not None:
88
+ return JsonResponse({"detail": exc.message, "schema": _plain(exc.schema)}, status=status)
89
+ return JsonResponse({"detail": exc.message}, status=status)
90
+
91
+
92
+ def _plain(value: Any) -> Any:
93
+ """``value`` with every mapping in it a ``dict``, at any depth.
94
+
95
+ The encoder writes only a ``dict`` as an object, and a schema may arrive
96
+ as any mapping - a frozen one, say - at the top or nested in a property,
97
+ where it would otherwise be a 500. Both container kinds are held by
98
+ ``test_a_schema_of_any_mapping_is_written_at_any_depth``.
99
+ """
100
+ if isinstance(value, Mapping):
101
+ return {key: _plain(item) for key, item in value.items()}
102
+ if isinstance(value, (list, tuple)):
103
+ return [_plain(item) for item in value]
104
+ return value