Serving a page¶
A spec served as a server-rendered page: the form that validates a post is the form the page renders, every page works as a plain form post, and a refused post comes back as the page again with the refusal placed on its form. The statuses a refused post answers are the JSON views', so a client reading one reads the other the same way.
SpecFormView serves
a spec whose Validator is a FormValidator as the page a person
fills that form in on: the form that validates the arguments is the form the
page renders.
The examples on this page serve a book's form, a plain Django form read as the spec's Validator:
class BookForm(forms.Form):
title = forms.CharField(max_length=100, help_text="As printed on the cover.")
price = forms.DecimalField(max_digits=6, decimal_places=2)
status = forms.ChoiceField(choices=Status.choices)
published_on = forms.DateField(required=False)
author = forms.ModelChoiceField(queryset=Author.objects.all())
shelves = forms.MultipleChoiceField(
choices=[("fiction", "Fiction"), ("poetry", "Poetry")], required=False
)
def clean(self) -> dict[str, Any]:
cleaned = super().clean() or {}
if cleaned.get("status") == Status.PUBLISHED and not cleaned.get("published_on"):
raise forms.ValidationError("A published book needs its publication date.")
return cleaned
# The form is read here, once: a field with no JSON type fails on this line.
book_validator = FormValidator(BookForm)
class IsSignedIn(PermissionCheck):
message = "Sign in to add a book."
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
def add_book(*, data: dict[str, Any]) -> Book:
# ``data`` is the form's cleaned_data: a Decimal price, a date, the Author
# row. ``shelves`` is the form's own, and no column of the book's.
columns = ("title", "price", "status", "published_on", "author")
return Book.objects.create(**{name: data[name] for name in columns})
# BookForm, from the forms adapter's page, is the Validator and the page's form.
add_book_spec = ServiceSpec(service=add_book, permissions=[IsSignedIn()], validator=book_validator)
class AddBookThenShowIt(SpecFormView):
spec = add_book_spec
template_name = "spec_form.html"
def get_success_url(self, result: DispatchResult) -> str:
# The row the service created, which no static success_url can name.
return reverse("book", kwargs={"pk": result.value.pk})
urlpatterns = [
path(
"books/new/",
SpecFormView.as_view(
spec=add_book_spec,
template_name="spec_form.html",
success_url=reverse_lazy("books"),
),
),
path("books/add/", AddBookThenShowIt.as_view()),
path(
"books/<int:pk>/edit/",
SpecFormView.as_view(
spec=edit_book_spec,
template_name="spec_form.html",
success_url=reverse_lazy("books"),
),
),
path("books/", books, name="books"),
path("books/<int:pk>/", book, name="book"),
]
The template is any template that renders the form inside a POST form with its token. The one above is as small as that:
GET renders template_name with an unbound form under form, and view
and spec beside it: 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, so the page is never offered to a
principal the post would refuse: an anonymous visitor above gets Django's own
PermissionDenied, which the host's 403 page answers.
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- raisesPermissionDenied, as the post would be refused; a missing row, or a selector'sServiceNotFound, isHttp404; and a route value the spec's parameters refuse is Django'sBadRequest, which the host's 400 page answers. - The form stays unbound, and shows the row's values for its own fields. A
ModelFormof the row's model is built on a copy of the row, as theFormValidatorbinds it, so its own__init__sees the row it edits. Any other form takes the row's values for the fields it names as itsinitial, and a field no model field matches keeps its own, as does a disabled one, which the post cleans from that initial rather than reading. - So a file the row holds is shown, linked, with the
clear checkbox an optional file's
ClearableFileInputoffers beside it. - A create, a collection target and a missing row the instance selector's
allow_nonelets through show an empty form.
def book_by_pk(*, pk: int) -> QuerySet[Book]:
return Book.objects.filter(pk=pk)
def edit_book(*, instance: Book, data: dict[str, Any]) -> ChangeResult[Book]:
return update_from_input(instance, data, exclude_fields=["shelves"])
# The same form behind an update: GET finds the book the route names, and the
# page shows its values for the form's fields.
edit_book_spec = ServiceSpec(
service=edit_book,
permissions=[IsSignedIn()],
validator=book_validator,
instance_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=book_by_pk,
reads=Parameters.of(Parameter("pk", "integer", required=True)),
),
)
The URLconf above routes it as books/<int:pk>/edit/.
POST starts as GET does: the route first, then the same 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 the check returns goes to dispatch, so it runs once.
Then the post finds its target, as the page 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, and shows its held file and the clear checkbox beside it. And
the target answers before the shape check: a principal the object-level or
scope check refuses is PermissionDenied, and a missing row Http404, even
for a post that is malformed, where dispatch's own order would refuse the
malformed post first and re-render the page for them. Dispatch is handed the
target and the grant resolve_target returns, so it looks nothing up again and
asks no check twice. One case is left to dispatch: a route value the target's
reads refuse, which comes back in dispatch's one refusal beside the form's
own, as a non-field error. A create resolves None and a collection target
its set, each handed on the same way.
POST then binds the form to the post and reads the arguments through the form's own widgets, which is how Django reads a form:
- A checkbox is
TrueorFalseand a multi-select a list, where a flat reading would refuse 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, since the form ignores what is posted for it. - A field left blank is absent, for every field, as it is to the form. An
optional date, number or choice left empty is simply not sent, and a
required field left empty is refused with
"This field is required.", once. - A number, a date or a date-time in one of the field's input formats -
10/25/1974, a localized12,50,5.0for an integer - is read by the field's ownto_pythonand sent on in the form the shape check reads: an ISO date, an ISO date-time with its offset, a decimal string, a number. So the shape check never refuses what the form accepts. A value the field refuses is sent on as it came, and refused in the kernel's words, which are the field's own. - A ticked clear checkbox is read by its
file's widget, which answers
False, and is sent on as the argument the Validator declares for it,attachment-clear, so the file is cleared. Ticked beside an upload, both are sent, and the form refuses the pair in its own words. - The URL kwargs are merged last, so the route wins a clash, and typed with
coerce_flatin the same call as everything else: one refusal carries every problem. Each is an argument, so the spec declares each one. A kwarg the route captures and the spec does not declare is the host's misconfiguration, wrong for every caller, so it raisesImproperlyConfiguredunder eitherunknown_argumentspolicy - the policy governs what a client sends - rather than blaming the client with a refused post.
Then dispatch. A success redirects to
get_success_url(result), which resolves success_url as Django's
redirect() does, so a path, a reverse_lazy or a URL name all work. Override
it to read the result, as AddBookThenShowIt does to reach the book it
created. A refusal:
| Refusal | The form view answers |
|---|---|
InvalidArguments, ServiceValidationError |
400, the form re-rendered |
ActionUnavailable, ServiceConflict |
409, the form re-rendered |
AdditionalInputRequired, ServiceError |
422, the form re-rendered |
UnsupportedMediaType |
415, the form re-rendered |
DispatchError |
400, the form re-rendered |
NotPermitted, PrincipalUnavailable |
PermissionDenied |
ServiceNotFound, a not-found result |
Http404 |
The first two rows place the refusal on the form, and a service's string or
list detail is about the whole form. Every other re-rendered refusal is its
message, among the form's non-field errors. The re-rendered form carries
the refusal and nothing else, so the page says what dispatch decided. The
page's own form is never validated: its errors start empty and the refusal is
placed on it, so nothing the Validator did not decide reaches it, and a value
the Validator refused because no column holds it - a key past 64 bits - never
reaches the database through the page's own checks. So after a shape-check
refusal - a required field left blank, a date that does not parse - the
form's further checks, such as a length, clean() or uniqueness, answer the
next post rather than this one. The statuses are
error_response's, so a client reading a refused post - Turbo,
which will not render a failed post answered 200 - reads its outcome from the
status.
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 neither a
success_url nor its own get_success_url. The view is sync
only in this release.
The page reads no htmx header in this release, so an htmx request is
answered as any other post. A success is the redirect to the success URL, which
the request follows itself, so the page that URL serves is swapped into the
request's target. A refusal is the whole page at its 4xx status, which htmx does not swap
by default. A fragment answer for an htmx request, with HX-Redirect on a
success, is planned for 0.2.0. Until then, serve an htmx form from a view of
your own.