Declaring an operation¶
An operation is a spec: a frozen dataclass that says what the operation takes, who may run it, what it acts on and what it returns. There are two.
SelectorSpecis a read: a list or a retrieve.ServiceSpecis a write: a service, the row or rows it acts on, and what it returns.
Specs are usually built at module level, at import time. Anything that could
need the app registry - reading a Validator's declaration, resolving an
annotation - happens on first use instead, so building a spec never does.
A declaration no reader could act on is refused where it is written, with
ImproperlyConfigured naming the field: a Validator class where an instance
belongs, both an instance and a collection selector, a presenter declared on a
service and on its output selector, a string where select_related wants a
sequence.
Every callable declares what it receives¶
The service, the selector, extend_queryset and a pool seed's resolver are all
called the same way: with the keywords their signature declares, picked from a
pool dispatch builds for the call, and nothing else. A callable that takes
**kwargs receives the whole pool.
| Name | Holds | In the pool of |
|---|---|---|
user |
The principal | Every callable |
| each validated value, by name | What the Validator returned | The service |
data |
All of the validated values, as one mapping | The service |
instance |
The row an instance selector resolved | The service |
collection |
The rows a collection selector resolved | The service |
each of a selector's reads, by name |
The checked argument | That selector |
result |
The service's return | The output selector |
queryset |
The shaped queryset so far | extend_queryset |
| a registered seed | Its resolver's value | Every callable |
The fixed names - user, data, instance, collection, result and
queryset, with progress held back for later - are
reserved:
a parameter declared under one is refused, and so is a validated value
returned under one, so an argument can never outrank a value dispatch put in
the pool itself. A caller naming an argument user is the case that matters.
Pool seeds add names of your own, reserved the
same way.
A read¶
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Annotated, Any
from django.db.models import QuerySet
from django_service_specs import (
UNSET,
DataclassPresenter,
DataclassValidator,
FieldMarking,
Parameter,
Parameters,
PermissionCheck,
SelectorKind,
SelectorSpec,
UnsetType,
)
from tests.adapter_app.models import Status
from tests.dispatch_app.models import Note
class IsSignedIn(PermissionCheck):
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
@dataclass
class NoteRow:
id: Annotated[int, FieldMarking.handle()]
title: Annotated[str, FieldMarking.label()]
def notes_of(*, user: Any, search: str = "", ordering: str = "title") -> QuerySet[Note]:
return Note.objects.filter(owner=user, title__icontains=search).order_by(ordering)
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"),
),
select_related=["owner"],
presenter=DataclassPresenter(NoteRow),
)
kind is SelectorKind.LIST
or RETRIEVE. A retrieve returns one row: a queryset is read with .first(),
a DoesNotExist raised by a selector written with .get() means the same
missing row, and a missing row is reported as not-found unless the spec sets
allow_none=True, which makes it the value None - the shape of an optional
singleton, or of an upsert's target.
reads is the selector's whole input. There is no Validator in front of a
read, so these Parameters are what the closed argument set and the shape check
hold the caller to: an argument the spec does not declare is refused before
the selector runs.
select_related, prefetch_related and annotations are applied to the
queryset the selector returns, in that order, and extend_queryset last, with
the shaped queryset as queryset. Declaring any of them on a selector that
returns something other than a queryset is refused at dispatch.
permissions is required on a spec that is dispatched or registered, and
ignored on a selector nested inside a ServiceSpec: authorization belongs to
the spec being dispatched.
A write¶
A ServiceSpec names its service and, at most, one target selector:
instance_selector_spec, aRETRIEVE, for a service that acts on one row. It arrives asinstance, and a missing row is not-found: the service never runs and the Validator is never asked. Withallow_none=Trueon the selector, a missing row arrives asinstance=Noneinstead, which is how an upsert is written.collection_selector_spec, aLIST, for a bulk operation. The rows arrive ascollection.- Neither, for a create.
output_selector_spec re-reads what the service produced, with its return in
the pool as result. It is how a write returns a row with fresh annotations or
prefetches, or a list after a bulk change; the
relation-write example uses one.
atomic=True, the default, runs the service inside transaction.atomic().
metadata holds a project's own per-operation facts, stored as given, for its
own checks or audit hooks to read.
spec.parameters() is everything the write takes: its target selector's
reads, then its Validator's parameters. A name both declare is refused -
two sources claiming one argument is a declaration bug, and a last-wins merge
would hide which declaration a transport describes. The selector sees only its
own reads, and the Validator only its own parameters.
Parameters¶
Parameter is one thing
an operation takes, and every transport describes itself from it rather than
from whichever validation library the spec happens to use: a JSON Schema,
argv options, the closed argument set and the shape check are all derived from
the same declaration.
typeis a JSON type:string,integer,number,boolean,arrayorobject. JSON's own, because that is what crosses every wire the kernel serves: a JSON-RPC call, a queue row, and argv once it has been read.formatisdecimal,date-timeordate, on astring: a value JSON cannot carry, which travels as a string for the Validator to decode.itemsis an array's element: a JSON type name for a flat list, or theParametersof the object each row is.fieldsis the same for anobject. Nesting is what lets a write declare its rows, so a malformed row is refused by the declaration rather than reaching a constructor.choices,nullableandrequiredmean what they say.choiceson an array constrains each element.items_nullableis the element's own nullability, aslist[int | None]declares it: whether anullmay stand where an element would. It is independent ofnullable, which is the array's, and it needsitems, since an undeclared element admits anything already.defaultisUNSETwhen none is declared, so a default ofNonestays a real default. It is reported, not applied: whether an omitted argument arrives as its default is the validating library's behaviour.
There are no bounds (maxLength, minimum) on a Parameter; the Validator
enforces those.
Parameters is an
ordered set of them. Parameters.of(a, b) builds one, + joins two, and a
name declared twice is refused either way.
The Validator¶
A Validator turns
JSON-like arguments into the values a service receives. The contract is two
methods:
parameters()declares what it takes, asParameters. It must never query, because a transport describes a spec before any call.validate(arguments, context)returns the validated values keyed by name, or raisesInvalidArguments. It receives only the arguments its ownparameters()declare, already through the shape check, and aValidationContextcarrying the principal and the resolved target. It may query - a uniqueness check does - and the target is there so that an update's check can exclude the row being updated.
Three things a newcomer may look for are absent on purpose:
- It is nominal. An adapter subclasses
Validator, and subclassing is the opt-in. A structural check on a method name would let stock classes through by accident: anis_validmethod matches a Django form, avalidatemethod matches a pydantic model class, and neither was written to this contract. - There is no
partial,instanceormany. Those are one HTTP framework's update and list semantics. Off HTTP, what an update may omit is a property of the declared parameters, the row it acts on is target resolution, and a list is an array parameter. - It is sync-only. It may query, so the async entry point runs it in
Django's thread-sensitive executor, and it must not be
async def.
The dataclass adapter¶
DataclassValidator
is the reference adapter: what any adapter owes the kernel, with nothing but
the standard library and Django. It reads a dataclass's fields and annotations
into Parameters, and builds the instance from the arguments, nested
dataclasses included.
@dataclass
class BookIn:
title: str
price: Decimal
status: Status = Status.DRAFT
published_on: date | None = None
pk: int | None = None # present for a row that exists, absent for a new one
@dataclass
class AuthorIn:
name: str
books: list[BookIn] = field(default_factory=list)
@dataclass
class AuthorPatch: # every field may be left out, and then holds UNSET
name: str | UnsetType = UNSET
books: list[BookIn] | UnsetType = UNSET
# A spec takes a Validator instance: the adapter, wrapping the declaration.
author_validator = DataclassValidator(AuthorIn)
| Annotation | Declares |
|---|---|
str, int, float, bool |
Their JSON types. float also takes an integer; nothing else crosses types, so true is not an int. |
Decimal, datetime, date |
A string with the format it decodes from. A decimal also takes a JSON number. |
X \| None |
Nullable. Inside a list, list[X \| None], the element is: items_nullable. |
Literal[...], an Enum (Django's TextChoices and IntegerChoices included) |
Choices. An Enum decodes to the member. |
list[X] |
An array. list[SomeDataclass] is an array of rows, each declared as nested Parameters. |
| a dataclass | An object, with its fields as nested Parameters. |
X \| UnsetType |
The argument may be left out, and the field then holds UNSET. |
A field with a default or a default_factory is optional; one with neither is
required. An annotation outside the table is refused, naming the field.
What DataclassValidator(AuthorIn) declares, then: name, a required string,
and books, an optional array whose rows take a required title, a required
price (a string in the decimal format), a status with the choices
draft and published and the default draft, a nullable published_on in
the date format, and a nullable integer pk. Its validate() returns the
books as BookIn instances, which is what the relation writes read a row from.
Two rules on that example are easy to miss:
- A row that updates must declare its key. A relation write matches an
incoming row to an existing one by primary key, so
BookIndeclarespk: int | None = None: present for a book that exists, absent for a new one. Without it no incoming row matches, and every update replaces every row. Nothing fails when that happens, which is why it is said here. - An update's fields should be omittable.
AuthorIn.booksdefaults to an empty list, which is right for a create and wrong for an update: an author updated withoutbookswould arrive withbooks=[]and lose them all.AuthorPatchdeclares every fieldX | UnsetType = UNSET, so a field the caller left out arrives asUNSET, and the mutation helpers leave it alone.
An offset-less date-time is made aware in the current time zone when USE_TZ
is on, as Django's forms do, so a date-time means the same instant whichever
library validated it. Every failure is reported at once, as one
InvalidArguments tree; Arguments and refusals describes it.
Output and the Presenter¶
A Presenter turns one value
an operation produced into JSON-like data. It is the output-side counterpart of
the Validator:
output()declares the result as anOutput, an ordered tuple ofOutputField. It never queries. The declaration is what a reader that never renders needs: an agent tool's output schema, a command's table header when there are no rows, a confirmation page. An HTML transport hands the row to a template and never renders it, and still reads the declaration.present(value)renders one value. Dispatch'spresenthands a list's items over one at a time, so a Presenter never has to guess whether it was given a row or a collection. It may query, and it is sync-only.
An OutputField carries what a reader needs beyond a name and a type: a
label, choices as value-and-display pairs (a table shows the display, a
schema states it), always_present for a key that some rows omit, and a
FieldMarking saying
who the field is for. The
FieldAudience is
CONTENT by default, LABEL for the field that names the record,
HANDLE for an opaque identifier that is passed to other tools and never read
out to a person, and HIDDEN for plumbing. The kernel carries the marking on
the declaration; projecting a payload for an audience is a transport's job.
DataclassPresenter
declares the output as a dataclass and reads each field off the value by
attribute, so the value is usually a model row and is never converted into
the dataclass first. A nested dataclass field is read the same way, and a list
field may be a related manager, read through .all() so a prefetch is used.
A Decimal is rendered as its string, a date or date-time in ISO 8601, an
Enum as its value. A marking is declared in the field's annotation, as
NoteRow does above: Annotated[int, FieldMarking.handle()].
A read's presenter is its own presenter. A write's is its own, or its output
selector's when it declares none, and declaring both is refused. A presenter on
an instance or collection selector is never read, because the service consumes
those rows rather than returning them.