Skip to content

Serving over HTTP

django_service_specs.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.

from django_service_specs import AsyncSpecView, SpecView, adispatch_request, dispatch_request

It is the plumbing, not the pages: a view here answers JSON to a script, a fetch() or an htmx request. The one page it serves is a spec's own form, from SpecFormView, for a spec a Django form validates.

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 read whose value is None (an allow_none retrieve that found nothing) answers null at 200, since None is 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.

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 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. 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, because a JSON caller that sends "5" for an integer has sent a string, and the shape check says so. 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. Files are not arguments: a parameter has no file type. A POST's uploads are in request.FILES for the view, since Django parses them; it fills request.FILES for POST alone, and the uploads in a PUT, PATCH or DELETE body parsed here are dropped.

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 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.

Refusals

error_response answers both families, with the statuses djangorestframework-services uses. One body differs 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:

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}
ServiceConflict 409 {"detail": message}
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.

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))),
]

Forms

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.

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/", 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:

<form method="post">{% csrf_token %}{{ form }}<button name="save">Save</button></form>

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. The form is always unbound; showing an update's current row as its initial data is a page of its own, which this view does not build.

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; the object-level check is still dispatch's.

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 True or False and 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 localized 12,50, 5.0 for an integer - is read by the field's own to_python and 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.
  • The URL kwargs are merged last, so the route wins a clash, and typed with coerce_flat in 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 raises ImproperlyConfigured under either unknown_arguments policy - 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
ServiceConflict 409, the form re-rendered
ServiceError 422, 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 bound to no row, so its own errors would tell an update page that an unchanged unique value was taken. 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 - htmx, or Turbo, which will not render a failed post answered 200 - reads it the same way it reads the JSON views.

as_view() refuses, when the URLconf is imported, a view with no ServiceSpec, a spec whose Validator is not a FormValidator, and a view with neither a success_url nor its own get_success_url. The view is sync only in this release.

A refusal, placed on the form

add_argument_errors is how the view places a refusal, on a form whose own errors it has cleared, and a hand-written view with its own bound form can call it the same way. 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.