Serving over HTTP¶
django_spec_html.http serves an operation from a Django view with
nothing between the request and dispatch: no API
framework, no serializer, no router. A view reads the principal and the
arguments off the request, dispatches, and answers the result or the refusal
as JSON.
It is the plumbing, not the pages: a view here answers JSON to a script or a
fetch(). A page a person reads, such as a spec's own form
rendered and re-rendered with its refusal, is a page view. The pieces
the entry points are built from are public so that such a view reads a request
by the same rules: request_principal,
route_arguments and
add_argument_errors.
The examples on this page serve two specs, a list of the principal's notes and a rename of one, declared as any spec is:
@dataclass
class Rename: # what the write takes
title: str
@dataclass
class NoteOut: # what both answer, per note
id: int
title: str
class IsSignedIn(PermissionCheck):
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
class IsOwner(IsSignedIn):
message = "Only the note's owner may rename it."
def has_object_permission(self, principal: Any, spec: Any, target: Any) -> bool:
return target.owner_id == principal.pk
def notes_of(*, user: Any, search: str = "", ordering: str = "title") -> QuerySet[Note]:
return Note.objects.filter(owner=user, title__icontains=search).order_by(ordering)
def rename(*, instance: Note, title: str) -> Note:
instance.title = title
instance.save(update_fields=["title"])
return instance
list_notes_spec = SelectorSpec(
kind=SelectorKind.LIST,
selector=notes_of,
permissions=[IsSignedIn()],
reads=Parameters.of(
Parameter("search", "string", help="Part of the title, in any case."),
Parameter("ordering", "string", choices=["title", "-title"], default="title"),
),
presenter=DataclassPresenter(NoteOut),
)
rename_note_spec = ServiceSpec(
service=rename,
permissions=[IsOwner()],
validator=DataclassValidator(Rename),
instance_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=lambda *, pk: Note.objects.filter(pk=pk), # the row, or none
reads=Parameters.of(Parameter("pk", "integer", required=True)),
),
presenter=DataclassPresenter(NoteOut),
)
A function view¶
dispatch_request
is the whole of what a hand-written view calls:
def rename_note(request: HttpRequest, pk: int) -> HttpResponse:
# The route's pk is an argument beside the body's title, merged last, so a
# body naming another pk cannot move this view off note ``pk``.
return dispatch_request(rename_note_spec, request, url_kwargs={"pk": pk})
async def arename_note(request: HttpRequest, pk: int) -> HttpResponse:
return await adispatch_request(rename_note_spec, request, url_kwargs={"pk": pk})
It reads the principal and the arguments, dispatches with the grant,
pool_seeds and unknown_arguments it is given, and answers:
- A not-found result is 404,
{"detail": "Not found."}. - A success is the presented value, bare, with no envelope around it. The
status is the caller's
success_status, or else 204 for a service with nothing to present and 200 for everything else. A 204 has no body. A service with nothing to present at any other status the caller names answers an empty body with noContent-Type, at that status. djangorestframework-services answers the same, except that where an output selector ran and found nothing it answers 204 whatever the caller named. Here the status stays the caller's: the caller named it, and an empty re-read already has a meaning, the valueNone. A read whose value isNone(anallow_noneretrieve that found nothing) answersnull, sinceNoneis its value. - A refusal of either family is
error_response's answer. 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.
adispatch_request
does the same from an async view, through adispatch and apresent. It reads
the request in an executor hop of its own, because touching request.user is
a session query and Django refuses one on the event loop.
The class form¶
SpecView is one spec as a
view, and every attribute can be passed to as_view() or set on a subclass:
spec, success_status, unknown_arguments, pool_seeds and methods.
urlpatterns = [
# A read answers GET and HEAD, and its query string is its arguments:
# /notes/?search=draft&ordering=-title
path("notes/", SpecView.as_view(spec=list_notes_spec)),
# A write answers POST: the pk from the route, the title from the body.
path("notes/<int:pk>/rename/", SpecView.as_view(spec=rename_note_spec)),
# The same write as a PATCH, served from async code.
path("notes/<int:pk>/", AsyncSpecView.as_view(spec=rename_note_spec, methods=["patch"])),
# The function views above.
path("notes/<int:pk>/rename-by-hand/", rename_note),
path("notes/<int:pk>/arename-by-hand/", arename_note),
]
The methods follow the spec. A SelectorSpec answers GET and HEAD, a
ServiceSpec POST. methods replaces that with the ones it names, from GET,
HEAD, POST, PUT, PATCH and DELETE, in either case. Any other method is Django's
own 405, with an Allow header listing exactly the methods served, and
OPTIONS is Django's own answer.
as_view() checks the spec and the methods when the view is built, so a view
with no spec, or one naming a method it cannot serve, fails when the URLconf is
imported rather than on its first request. The spec may be a declaration of
another library's, a
SpecSource: it is
compiled there too, a declaration that does not compile is refused there, and
its methods follow the spec it compiles to.
AsyncSpecView is
the same view with every handler async, through adispatch_request. Django
serves a class async only when all of its handlers are, which is why it is a
class of its own rather than a setting.
Arguments¶
request_arguments
reads them, by method and body:
| Request | Read from | Coerced |
|---|---|---|
| GET, HEAD | the query string | yes, with coerce_flat |
any other method, application/json body |
the body, which must be an object | no |
| any other method, form or multipart body | the form fields, and a multipart body's uploads | yes, with coerce_flat |
| any other method, no body | nothing | - |
| any other method, any other body | refused, 415 | - |
A flat source is read by the declaration. An array parameter takes every
value its key was sent with (?ids=1&ids=2), and anything else takes the last.
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
each part trimmed: a comma cannot be part of such a value, and it is the
spelling a django-filter CSV filter advertises. A decimal element stays the
string it spells, so ?pks=1,2 is ["1", "2"], every digit kept. Repeated keys and commas may mix, and an empty part
(?ids=1,,2) is refused at its index as any malformed element is. A list
longer than the array takes - its max_length, or 1000 by the
SERVICE_SPECS_MAX_ARRAY_LENGTH setting, which is the kernel's and keeps its
name - is refused whole at its name, a 400
rather than the 500 a database answers past its variable limit, and is never
split further than one element past it: see
how long a list may be. An array
of strings is split the same way when it declares choices and none of them
holds a comma, as a django-filter OrderingFilter's do: every element must be
a choice, so ?o=title,-pk can only be ["title", "-pk"]. An array of
strings with no choices, or with a choice holding a comma, is never split,
since a comma can be part of a string, and an array declaring no items is
not either.
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. Django's csrfmiddlewaretoken is never an argument. Then
coerce_flat types the strings, and leaves an
undeclared key for the closed argument set to refuse under the caller's policy.
A JSON body is read as it is. Its values are not coerced here: a "5"
sent for an integer is the shape check's to read, and it reads it as 5, as it
reads one from any caller (see the shape check).
A body that does not parse, or is not an object, is refused as
InvalidArguments under non_field_errors. An empty body is no arguments.
A body in any other format is refused as
UnsupportedMediaType,
answered 415, rather than read as no arguments: a PATCH sent as text/plain
or application/merge-patch+json to an operation whose parameters are all
optional would otherwise run with nothing and answer success. A request with
no body carries no arguments whatever its Content-Type says, so a
fetch(url, {method: "POST"}) for an operation its route names is served.
Django parses a form body into request.POST for POST alone, so a PUT, PATCH
or DELETE form is parsed here the same way rather than arriving empty. 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 POST's uploads are Django's own request.FILES;
those in a PUT, PATCH or DELETE body are parsed here with its fields, by
Django's own parser from the request's stream, so a file part is streamed to
the upload handlers and DATA_UPLOAD_MAX_MEMORY_SIZE bounds the fields alone,
as it does for a POST. Like a POST, such a body is gone once parsed: reading
request.body afterwards, or calling request_arguments on the request a
second time, raises Django's RawPostDataException. The entry points here
read the arguments once, so this reaches only code that reads the body after
the view - a middleware, an audit log, an error reporter. Reading
request.body before the view keeps the body, and the arguments are then
parsed from it as often as they are asked for; that read is a whole-body
read, so DATA_UPLOAD_MAX_MEMORY_SIZE bounds the whole body again, uploads
included, and a larger one raises RequestDataTooBig. An
upload under a name the spec does not declare reaches the closed argument set
like any undeclared field, and is refused under REJECT; a file input left
empty is no upload, so it is absent. Only a multipart body carries one.
An optional file's clear checkbox is a field of
the same body, attachment-clear=true, and is read as every flat boolean
is: true, false, 1 or 0. A checkbox with no value attribute posts
on, which is refused, so a page posting to a SpecView gives it
value="true".
The URL kwargs are arguments too, merged last so the route wins a clash, as
it does in djangorestframework-services. A client-supplied value must not move
the route's scope: a POST to /notes/4/rename/ whose body says "pk": 9
renames note 4 or nothing. A path segment with no converter arrives as a
string and goes through coerce_flat like a query value. Every URL kwarg is
an argument, so the spec declares each one. A route capturing a kwarg its spec
does not declare raises ImproperlyConfigured on the first request it
serves, under either unknown_arguments policy: the mismatch is the host's,
wrong for every request, and a 400 would tell the client it had sent
something wrong. A host scoping by a kwarg its spec does not take (an org
read by middleware, say) calls
dispatch_request
from its own view and passes the url_kwargs the spec declares.
The route on its own¶
route_arguments
is that rule on its own, for a view that reads the rest of its arguments some
other way, such as through a bound form's widgets. It returns the kwargs as a
plain, uncoerced copy, or raises ImproperlyConfigured naming every one the
spec does not declare, so the caller merges it last and types it in the same
call as everything else, and one refusal carries every problem:
def form_arguments(posted: Mapping[str, Any], url_kwargs: Mapping[str, Any]) -> dict[str, Any]:
# A view reading its fields some other way merges the route last, so the
# route wins a clash, and types everything in one call.
parameters = rename_note_spec.parameters()
return coerce_flat(parameters, {**posted, **route_arguments(parameters, url_kwargs)})
The principal¶
The principal is request.user, anonymous included: the spec's permission
check is what decides whether anonymous may act, so it is handed the anonymous
user rather than being pre-empted.
A deactivated account is refused, as PrincipalUnavailable (403), before
anything else is read. Django's ModelBackend never logs one in, but
AllowAllUsersModelBackend and a project's own backend may, and a deactivated
principal never acts, on HTTP or off it. dispatch refuses one by the same
rule, so a hand-written view handing request.user to it is covered too; the
entry points refuse first so that the request is never read.
request_principal
is that reading, public, so a host's own view refuses by the same rule and
before it reads anything else. It raises PrincipalUnavailable for a
deactivated account and returns request.user otherwise. Reading
request.user is a session query, so an async view calls it off the event
loop, as adispatch_request does.
Refusals¶
error_response
answers both families, with the statuses
djangorestframework-services uses. 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} |
ServiceConflict |
409 | {"detail": message} |
AdditionalInputRequired, with a schema |
422 | {"detail": message, "schema": schema} |
ServiceError |
422 | {"detail": message} |
UnsupportedMediaType |
415 | {"detail": message} |
DispatchError |
400 | {"detail": message} |
A row is read top to bottom and the first match answers, so a subclass is
matched before its base. Every 400 body is a field map: a
ServiceValidationError's string or list detail goes under
non_field_errors, where a message about the input as a whole sits in the
tree too, and the tree's integer row keys are strings on the wire. Messages
render in the active language, lazy ones included.
A schema no route could hear answered is not a 422. dispatch_request and
adispatch_request pass an AdditionalInputRequired schema through
check_requested_input
before writing it: a schema naming a key the spec does not declare, or a shape
an MCP elicitation form cannot render, raises ImproperlyConfigured naming the
key, as it does on every other route. error_response alone has no spec in
hand and writes whatever it is given.
Never 401. A session 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.
CSRF¶
CSRF is the host's middleware, as it is for any Django view. Nothing in the
package is exempted: a POST from a session-authenticated browser carries the
token, in the X-CSRFToken header for a fetch() or in the form for an HTML
form, and CsrfViewMiddleware refuses one that does not. A route that needs no
token is the host's decision, and one line:
# A route a script calls, authenticated by the host's own middleware rather than
# a session cookie, so it has no CSRF token to send. Exempting it is the host's
# decision, and one line.
urlpatterns += [
path("hooks/notes/<int:pk>/rename/", csrf_exempt(SpecView.as_view(spec=rename_note_spec))),
]
A refusal, placed on a form¶
add_argument_errors
places a refusal on a host's own bound form, for a page that re-renders the
form it posted with what dispatch refused. It uses Django's public
form.add_error, beside whatever errors the form carries:
- A key naming one of the form's fields puts its messages on that field.
non_field_errors, and Django's own"__all__", go to the form's non-field errors.- 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: Enter a whole number.". A nested tree is flattened with its path joined by dots,"books.1.title: This field is required.", since a form is flat. Below a field the path is an array's element index, which means nothing to a person reading one control, so it is dropped there. - Never a duplicate. A message the form already carries at the same place
is not added again. The form validating the same post says what the kernel
says - a
FormValidator's refusal is the form's own 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.errorswould, so its own errors are the ones compared against, but with the guards aFormValidator's form is validated with. Its own validation would hand the database driver a number no integer column holds - a model choice field's key, aModelForm's integer - which SQLite's refuses withOverflowError. Such a key is refused asinvalid_choiceand such an integer asmax_valueormin_value, in the words aFormValidatorrefuses them with, so the refusal of one is placed once. A form already validated is compared as it stands.