API reference¶
Everything below is exported from django_spec_html directly, and from the
subpackage it is listed under. Import from the package root in application
code; the subpackage paths are where each name is defined. Kernel names these
views read - ServiceSpec, dispatch, the refusals - are documented on
the kernel's site.
Settings¶
This package defines none. The argument reader reads one of the kernel's:
SERVICE_SPECS_MAX_ARRAY_LENGTH: the longest list an array parameter with nomax_lengthof its own takes. It is the kernel's key, read by the kernel, and keeps its name here. See how long a list may be.
Pages¶
SpecFormView ¶
Bases: TemplateResponseMixin, ContextMixin, View
A ServiceSpec validated by a form, served as the page a person fills that form in on.
The spec's Validator must be a
FormValidator,
and its form class is the page's form:
urlpatterns = [
path(
"books/new/",
SpecFormView.as_view(
spec=add_book_spec,
template_name="books/book_form.html",
success_url=reverse_lazy("books"),
),
),
]
GET renders template_name with an unbound form under form,
beside view and spec: the names Django's FormView uses, with
extra_context merged in as Django's ContextMixin does. The spec's
class-level permission check runs first, through
authorize, so
a page is never offered to a principal the post would refuse.
An update's page shows the row it updates. Every GET then finds its
target as dispatch would, through
resolve_target
on the route's kwargs, coerced as a post's are: the row or set a target
selector resolves, or None for a create. A check refusing it - the
object-level check on a row, the scope check on a set or on None -
raises PermissionDenied, as the post would be refused; a missing row
or a selector's ServiceNotFound raises Http404; and a route value
the spec's parameters refuse raises Django's BadRequest. The form
shows a row's values for its own fields: a ModelForm of the row's
model is built on a copy of it, as the Validator binds one, and any other
form takes the row's values for the enabled fields it names as its
initial. So a file the row holds is shown, with the clear checkbox an
optional file's ClearableFileInput offers beside it. A create, a
collection target and a missing row allow_none lets through show an
empty form.
POST starts as GET does: the route first, then that check, and only
then is the form built from the post. A refused post is answered with the
page again, and dispatch's shape check comes before its own permission
check, so without it a principal the spec refuses would be shown the
page - every row a choice field lists, and extra_context - by posting
a malformed form, and a form whose constructor reads rows would read them
for that principal. The
Grant it returns goes
to dispatch, so the class-level check runs once.
Then the post finds its target, as GET does, before the form is
built or read, so the form a refused post re-renders is built on the row
as the page's was, its held file and clear checkbox shown. The target
answers before the shape check: the object-level and scope checks and a
missing row refuse a malformed post as they would a well-formed one,
rather than dispatch re-rendering the page for a principal the row
refuses. Dispatch is handed the target and the grant resolve_target
returns, so it looks nothing up again and asks no check twice. A route
value the target's reads refuse is left to dispatch, whose one refusal
carries it beside the form's own.
POST binds the form to the post and reads the arguments through its
own widgets - a checkbox is True or False, a multi-select a list -
because that is how Django reads a form, and a flat reading refuses a
checkbox's "on". Only the form's fields are read, so the CSRF token
and a named submit button never become arguments, and a disabled field is
not read at all, as the form ignores what is posted for it. A field left
blank is absent, as it is to the form. A number, date or date-time the
field reads in one of its input formats - 10/25/2006, a localized
12,50 - is sent on in the form the shape check reads, through the
field's own to_python, so the shape check never refuses what the form
accepts. The URL kwargs are merged last, so the route wins a clash, as in
request_arguments.
Each is an argument, so the spec declares each one: a kwarg it does not is
the host's misconfiguration, raised as ImproperlyConfigured under
either unknown_arguments policy rather than answered as a refused post.
Then dispatch:
- A success redirects to
get_success_url(result):success_urlthrough Django'sresolve_urlby default, so areverse_lazyor a URL name works, and a subclass overrides it to read the result. - A refusal of the arguments -
InvalidArguments, or aServiceValidationError- re-renders the bound form at 400 with the refusal placed on it byadd_argument_errors. A service's string or list detail is about the whole form. - Any other refusal re-renders it with the message as a non-field
error:
ServiceConflictat 409, any otherServiceErrorat 422,UnsupportedMediaTypeat 415, any otherDispatchErrorat 400. The statuses areerror_response's, read from the one map, so a client reading a refused post - Turbo, which will not render a failed post answered 200 - reads it the same way. NotPermittedandPrincipalUnavailableraise Django'sPermissionDenied, and a not-found result orServiceNotFoundraisesHttp404, for the host's own 403 and 404 pages.
The view reads no htmx header, so an htmx request is answered as any other post: a success is the redirect, which the request follows, and a refusal is the whole page at its 4xx status, which htmx does not swap by default.
A re-rendered form carries the refusal and nothing else: the page says
what dispatch decided. The page's own form is never validated: its
errors start empty and the refusal is placed on them, so nothing the
Validator did not decide reaches the page, and a value the Validator
refused because no column holds it never reaches the database through
the page's own checks. After a shape-check refusal, then,
the form's further checks - a length, clean(), uniqueness - answer the
next post rather than this one.
as_view() refuses, when the URLconf is imported, a view with no
ServiceSpec, a spec whose Validator is not a FormValidator, a
many spec, whose list of rows no form posts, and a view with no
success_url that does not override get_success_url.
Sync only, in this release. CSRF is the host's middleware, as for
any Django view, and the template renders {% csrf_token %}.
Attributes:
| Name | Type | Description |
|---|---|---|
spec |
ServiceSpec | None
|
The operation served. Required. |
template_name |
ServiceSpec | None
|
The page's template, as for any Django template view. |
success_url |
Any
|
Where a successful post redirects, read by
|
pool_seeds |
PoolSeeds
|
The seeds the spec's callables are resolved with. |
unknown_arguments |
UnknownArguments
|
What becomes of an argument no parameter declares. |
as_view ¶
Django's as_view, then the spec and the redirect checked, once, for every request.
get ¶
The page, with an unbound form showing the target row, for a principal admitted to it.
post ¶
Dispatch the posted form: a redirect on success, the form re-rendered on a refusal.
get_context_data ¶
Django's context - view, and extra_context - with the spec served.
get_success_url ¶
Where a successful post redirects: success_url, resolved as redirect() reads it.
Override it to read result, such as the row a create returned.
JSON views¶
SpecView ¶
Bases: SpecViewBase
One spec as a view: each request it answers goes through dispatch_request.
Every attribute can be set on a subclass or passed to as_view():
urlpatterns = [
path("notes/", SpecView.as_view(spec=list_notes_spec)),
path("notes/<int:pk>/rename/", SpecView.as_view(spec=rename_note_spec)),
]
The methods follow the spec. A SelectorSpec answers GET and HEAD, a
ServiceSpec POST, and methods replaces that with the ones it names,
from GET, HEAD, POST, PUT, PATCH and DELETE. Any other method is Django's
own 405, whose Allow header lists exactly the methods served, and
OPTIONS is Django's own answer. The URL kwargs are arguments, merged
last so the route wins a clash, which means the spec declares each one:
a route capturing a kwarg its spec does not declare raises
ImproperlyConfigured on the first request it serves, since that is the
host's configuration rather than anything a client sent.
as_view() checks the spec and the methods when the view is built, so a
view with no spec fails when the URLconf is imported rather than on its
first request.
CSRF is the host's middleware, as for any Django view. Nothing here is
exempted, and csrf_exempt(SpecView.as_view(...)) is one line for a host
that wants it.
Attributes:
| Name | Type | Description |
|---|---|---|
spec |
ServiceSpec | SelectorSpec | SpecSource | None
|
The operation served. Required. |
success_status |
int | None
|
The status of a success, or |
unknown_arguments |
UnknownArguments
|
What becomes of an argument no parameter declares. |
pool_seeds |
PoolSeeds
|
The seeds the spec's callables are resolved with. |
methods |
Sequence[str] | None
|
The HTTP methods served, in either case, or |
answer ¶
Dispatch the spec for request, the route's kwargs as url_kwargs.
AsyncSpecView ¶
Bases: SpecViewBase
SpecView, with every handler async.
The same attributes, methods, checks and answers, through
adispatch_request.
Django serves a class-based view async only when every handler it defines
is async def, and refuses a class that mixes the two, so every method
a spec may be served on is async here, not only the ones it answers by
default. Under ASGI this keeps the request off a worker thread until the
kernel needs one; under WSGI Django runs it in an event loop of its own,
which costs more than SpecView and buys nothing.
Not a subclass of SpecView, for the same reason: an async handler
cannot stand in for a sync one.
answer
async
¶
Dispatch the spec from async code, the route's kwargs as url_kwargs.
dispatch_request ¶
dispatch_request(
spec: ServiceSpec | SelectorSpec | SpecSource,
request: HttpRequest,
*,
url_kwargs: Mapping[str, Any] | None = None,
success_status: int | None = None,
grant: Grant | None = None,
pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> HttpResponse
Dispatch spec for request and answer it as JSON: the function a view calls.
- The principal is
request.user, anonymous included, since the spec's permission check is what decides whether anonymous may act. An authenticated user whoseis_activeis false is refused asPrincipalUnavailable: a backend other thanModelBackendcan log one in, and a deactivated principal never acts. Dispatch refuses one by the same rule; refusing here as well keeps the request unread for it. - The arguments are
request_argumentsforspec.parameters(), withurl_kwargsmerged last. dispatch, withgrant,pool_seedsandunknown_argumentsas given.- The answer. A not-found result is 404 with a
detail. Otherwise the presented value, bare, atsuccess_statusif given, else 204 for a service with nothing to present and 200 for everything else - the defaults djangorestframework-services answers with. A 204 has no body.
A refusal of either family, from any of the four steps, is answered by
error_response.
Anything else propagates, a configuration error included: it is wrong for
every caller, so it belongs to the host's error handling rather than to one
client's response. A URL kwarg spec does not declare is one, raised
as ImproperlyConfigured: every URL kwarg is an argument, and the route
is the host's.
spec may be a
SpecSource: it is
compiled once, first, and every step reads the spec it compiles to.
CSRF is the host's middleware, as for any Django view: nothing here
exempts a view, and csrf_exempt is one line for a host that wants it.
adispatch_request
async
¶
adispatch_request(
spec: ServiceSpec | SelectorSpec | SpecSource,
request: HttpRequest,
*,
url_kwargs: Mapping[str, Any] | None = None,
success_status: int | None = None,
grant: Grant | None = None,
pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> HttpResponse
dispatch_request, from async code.
The same principal, arguments, statuses and refusals, reached through
adispatch and
apresent, so the
kernel's async rule holds: nothing that may query runs on the event loop.
That includes reading the request, which is one executor hop of its
own before adispatch's. request.user is the lazy object
AuthenticationMiddleware leaves, and its first read is a session query,
which Django refuses on the loop; Django 4.2, the floor, has no
request.auser() to await instead. spec.parameters() goes in the
same hop, because a Validator may read a model to declare what it takes.
Building the response does not query and stays on the loop.
error_response ¶
exc as JSON, at the status djangorestframework-services answers it with.
One map for both families, at djangorestframework-services' statuses, and
the one the page reads too.
Two bodies differ from its answer. A service's string or list detail, which
DRF answers as a bare list and this as a field map under
non_field_errors, so every 400 here has one shape. And an
AdditionalInputRequired schema, which DRF reads as an error detail and
so writes with every value a string ("minimum": "1"), and this writes as
the JSON Schema it is:
| Refusal | Status | Body |
|---|---|---|
InvalidArguments |
400 | the error tree |
ServiceValidationError |
400 | its detail, as a field map |
NotPermitted, PrincipalUnavailable |
403 | {"detail": message} |
ServiceNotFound |
404 | {"detail": message} |
ActionUnavailable |
409 | {"detail": message, "code": code} |
any other ServiceConflict |
409 | {"detail": message} |
AdditionalInputRequired with a schema |
422 | {"detail": message, "schema": schema} |
any other ServiceError |
422 | {"detail": message} |
UnsupportedMediaType |
415 | {"detail": message} |
any other DispatchError |
400 | {"detail": message} |
Every 400 body is a field map. InvalidArguments is its tree, whose
integer row keys JSON writes as strings. A ServiceValidationError's
mapping is its body as it is, and a string or a list goes under
non_field_errors, where a message about the input as a whole sits in
the tree too. A lazy translation is one message, not a sequence of
characters, and renders in the active language: JsonResponse's
encoder renders every lazy string it meets, messages included.
Never 401. A session-authenticated view has no challenge to answer, and a 401 tells a client to start an authentication flow it has no way to finish. An anonymous caller a permission check refuses is a 403, like any other refused principal.
Two members add a key beside detail, and keep their base's status.
ActionUnavailable is a conflict, and its arm exists only to put the
affordance's code in the body beside the detail a client already
reads: the stable code is the whole point of the member, and a client left
with only the sentence has nothing to branch on. AdditionalInputRequired
is "I need one more value", which is the operation being unprocessable as
asked, so it stays a 422 like any other service error; the schema naming
what is missing joins the body, because without it a client is told that
something is needed and not what. Only when there is a schema: the error is
valid without one, and growing the body unconditionally would change every
plain message into an object for no gain.
A DispatchError no row names - raised by nobody in this package, or a
subclass a later release adds - is still a refusal of the call rather than
the operation's own verdict, so it answers 400 rather than 422.
Reading a request¶
request_arguments ¶
request_arguments(
request: HttpRequest,
parameters: Parameters,
*,
url_kwargs: Mapping[str, Any] | None = None,
) -> dict[str, Any]
The arguments a request carries for parameters, typed as a JSON caller would send them.
Where they are read from depends on the method and the body:
- GET and HEAD: the query string, which is flat. Any body is ignored.
- Any other method, with an
application/jsonbody: the body, read as it is. It must be a JSON object; an empty body is no arguments, as DRF's parser reads one. Its values are not coerced here: a"5"sent for an integer is the shape check's to read, as it reads one from any caller. - Any other method, with a form body (
application/x-www-form-urlencodedormultipart/form-data): the fields, which are flat. Django parses one intorequest.POSTfor POST alone, so a PUT, PATCH or DELETE body is parsed here the same way rather than arriving empty. Any other body is refused asUnsupportedMediaTyperather than read as none, since an operation whose parameters are all optional would then run with nothing; a request with no body carries no arguments, whatever itsContent-Typesays.
A flat source is read by the declaration. A parameter of type
array takes every value its key was sent with, and anything else takes
the last, as QueryDict reads a repeated key. An array of numbers,
integers, booleans or decimal strings also reads a value holding commas
as its elements, ?ids=1,2 as [1, 2] with the space around a
part trimmed, because a comma cannot be part of such a value and it is
the spelling a django-filter CSV filter advertises; repeated keys and
commas may mix. A decimal element stays the string it spells. An
empty part (?ids=1,,2) is not dropped as a blank element is: it is
refused at its index as any malformed element is. A string array is
split only where its choices hold no comma, as an ordering's do, so
?o=title,-pk is ["title", "-pk"]; otherwise it is never split,
since a comma is part of a string. An array is read no further than one
element past the most it takes - its max_length, or the
SERVICE_SPECS_MAX_ARRAY_LENGTH setting's default - so a list over it is
refused at its name as a whole, without a million commas becoming a
million elements first; Django's DATA_UPLOAD_MAX_NUMBER_FIELDS counts
keys, and a comma list is one. A blank value is
absent unless "" is one the parameter can take: a plain string
can, and a number, a boolean, a date, a decimal, or a string whose
choices leave the blank out cannot. A flat wire has no other spelling of
"left blank", and an empty ?count= or ?since= from a filter form is
not a refusal to count or a malformed date. Each blank element of an array
is read by the same rule, the whole array absent once nothing is left. Django's
csrfmiddlewaretoken is dropped. What remains goes through
coerce_flat,
which leaves an undeclared key as it is, so the closed argument set in
dispatch stays the one place that refuses it, under the caller's policy.
An upload is an argument, under the name its part carries, as each
field is: the upload itself, Django's UploadedFile, for a parameter of
type file, every upload under the name for an array, and the last
of repeated ones for anything else. It replaces a field of the same name,
since a browser sends a file input only as a file: a single value reads as
the upload in DRF's merged data too, where an array there would hold the
field and the uploads together. A POST's uploads are Django's own request.FILES; those in a
PUT, PATCH or DELETE body are parsed here with its fields. An upload under
a name parameters do not declare passes through like an undeclared
field, for the closed argument set to refuse, and a file input left empty
is no upload, so it is absent. Only a multipart body carries one.
URL kwargs are merged last, through coerce_flat as well since a
path segment with no converter is a string. The route wins a clash, as it
does in djangorestframework-services: a client-supplied value must not
move the route's scope, so /notes/4/ with pk=9 in its body is about
note 4. On a flat request the two are coerced together, so every refusal
comes back at once and a clashing client value the route replaced is
never refused.
Raises:
| Type | Description |
|---|---|
InvalidArguments
|
a JSON body that does not parse or is not an object,
under |
UnsupportedMediaType
|
a body that is neither JSON nor a form. |
ImproperlyConfigured
|
a URL kwarg |
route_arguments ¶
The kwargs a route captured, as arguments, each one a parameter the spec declares.
How request_arguments reads the route, public so that a host's own
view reading the rest of its arguments some other way - a bound form, a
page's own fields - merges the route by the same rule and refuses the
same misconfiguration.
Every URL kwarg is an argument, so a route capturing one its spec does not
declare is the host's configuration, wrong for every request the route
serves, rather than anything a client sent. It is refused as one - Django
answers it 500 and shows it under DEBUG - where a client's own
undeclared argument is a 400. The unknown_arguments policy does not
reach it: that policy is about what a client sends, and no client can send
a route kwarg or stop sending one.
Returned as a plain copy and uncoerced, so each caller merges it last,
where the route wins a clash, and coerces it beside what the client sent:
coerce_flat(parameters, {**read, **route_arguments(parameters, url_kwargs)}),
so one refusal carries every problem with either.
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
naming every undeclared kwarg, in order. |
request_principal ¶
request.user, anonymous included, unless it is a deactivated account.
How dispatch_request, adispatch_request and both spec views read
the principal, public so that a host's own view - one rendering a page, or
reading its arguments some other way - reads it by the same rule, and
refuses before it reads anything else of the request.
Anonymous is passed through because a permission check is what decides whether anonymous may act, and the spec declares that check; refusing it here would make every spec's policy stricter than the one it states.
A deactivated account is refused, by the rule dispatch itself applies and
resolve_principal applies off HTTP: ModelBackend never logs one in,
but AllowAllUsersModelBackend and a project's own backend may. The rule
asks about authenticated users alone, since AnonymousUser.is_active is
False, and a custom user model with no is_active reads as active.
Dispatch would refuse the same user; refusing here as well is what keeps
the request unread for one, as adispatch resolves a principal_id
before looking at its arguments.
Touching request.user evaluates the lazy object AuthenticationMiddleware
leaves there, which is a session query, so an async caller calls this off
the event loop, as adispatch_request does.
Raises:
| Type | Description |
|---|---|
PrincipalUnavailable
|
an authenticated user whose |
add_argument_errors ¶
Place a refusal tree on a bound form, beside the form's own errors, never twice.
detail is an
InvalidArguments
tree, or a ServiceValidationError's mapping. Each message goes where a
person reading the form looks for it, through Django's public
form.add_error:
- A key naming one of the form's fields puts its messages on that field. A form is flat, so the only tree below a field is an array's element indices, which mean nothing to a person reading one control: the messages go on the field without them.
non_field_errors, and Django's own"__all__", go to the form's non-field errors as they are.- Any other key - a URL kwarg such as
pk, or a key a service chose - goes to the non-field errors prefixed with its path,"pk: ...", so the reader knows what it is about. A nested tree is flattened to its leaves with the path joined by dots,"books.1.title: ...", a row's ownnon_field_errorsadding nothing to the path.
Never a duplicate. A message the form already carries at the same place
is not added again. The form validating the same data says the same thing
the kernel does - a FormValidator's refusal is the form's errors, and
the shape check spells its messages as Django's fields do - so without this
most refusals would show twice. A form not yet validated is validated
first, as reading form.errors would, so the form's own errors are
always the ones compared against; but with the guards a
FormValidator's form is validated with, since the form's own
validation would hand the database driver a number no integer column
holds, which SQLite's refuses with OverflowError. A model choice
field's key past its column's range is refused as invalid_choice and
a ModelForm's value past its integer column's range as max_value
or min_value, in the words a FormValidator refuses them with, so
the refusal of one is placed once
(test_a_list_s_key_past_its_column_is_refused_once,
test_a_model_form_s_integer_past_its_column_is_refused_once). A form
already validated is compared as it stands.
A leaf is a message whatever its type: a string, a lazy translation (which
is not a str, and is never taken apart into characters), or a list of
either. The form must be bound, as Django's add_error requires.
Refusals¶
UnsupportedMediaType ¶
Bases: DispatchError
A request carried a body that is neither JSON nor a form.
Refused rather than read as no arguments. Read as none, a PATCH sent as
text/plain or application/merge-patch+json to an operation whose
parameters are all optional would run with nothing and answer success,
which tells its client the change was made. A request with no body is not
refused, whatever it is labelled, since there is nothing in it to misread.
A refusal of the call rather than the operation's verdict, so a
DispatchError, answered 415 as djangorestframework-services answers it.