django-service-specs¶
A service contract for Django. An operation is declared once - what it takes, who may run it, how its arguments are validated, what it returns - and any transport dispatches it: an HTTP view, an MCP tool, an agent tool, a management command, a background task.
Each transport would otherwise restate the operation in its own terms: a serializer for the view, a JSON Schema for the tool, argparse options for the command, and a permission check in whichever of them remembered one. Declared once, the operation carries all of that itself, and a transport reads the declaration rather than repeating it.
Two positions¶
Everything else follows from two decisions, and both read as limitations.
Django is the base, and the only one. There is no pure-Python protocol layer beneath the kernel and no Django-free subpackage inside it. A principal is a Django user, a target is a model row, a transaction is Django's. The package depends on Django and nothing else.
DRF is an adapter, never a dependency. The package exists so a Django
project with business logic and no API framework can hand its operations to an
agent, a command or a queue. Nothing in django_service_specs imports
rest_framework, and the test suite runs without it installed.
djangorestframework-services
will depend on this package, never the reverse, with its own ServiceSpec
becoming the DRF adapter over this kernel.
Install¶
The package has no models and needs no entry in INSTALLED_APPS. A principal
is a user of django.contrib.auth, and a generic-relation write needs
django.contrib.contenttypes.
| Minimum | |
|---|---|
| Python | 3.10 |
| Django | 4.2 |
Quickstart¶
A write that renames a note. Note is a model with a title and an owner
who is a user; the examples on these pages import their models from the
package's own test suite, which is where they run.
from dataclasses import dataclass
from typing import Any
from django_service_specs import (
DataclassPresenter,
DataclassValidator,
Parameter,
Parameters,
PermissionCheck,
SelectorKind,
SelectorSpec,
ServiceSpec,
dispatch,
present,
)
from tests.dispatch_app.models import Note # a title, and an owner who is a user
@dataclass
class Rename: # what the operation takes
title: str
@dataclass
class NoteOut: # what it returns
id: int
title: str
class IsOwner(PermissionCheck):
message = "Only the note's owner may rename it."
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
def has_object_permission(self, principal: Any, spec: Any, target: Any) -> bool:
return target.owner_id == principal.pk
def rename_note(*, instance: Note, title: str) -> Note:
instance.title = title
instance.save(update_fields=["title"])
return instance
rename_note_spec = ServiceSpec(
service=rename_note,
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),
)
def rename(user: Any, arguments: dict[str, Any]) -> Any:
result = dispatch(rename_note_spec, principal=user, arguments=arguments)
if result.kind == "not_found":
return None # a transport answers this in its own terms: a 404, an exit code
return present(rename_note_spec, result)
What each part is:
Rename, wrapped inDataclassValidator, declares what the operation takes. A transport reads the declaration asParametersto describe itself - a JSON Schema, argv options - and dispatch validates the arguments against it.- The instance selector finds the row the service acts on, from the
pkargument itsreadsdeclare. A missing row is reported as not-found and the service never runs. IsOwneris aPermissionCheck:has_permissionruns before any row is looked up,has_object_permissionon the row once it is.rename_noteis the service. It is called with the keywords it declares and nothing else: the resolved row asinstance, and each validated value by name.NoteOut, wrapped inDataclassPresenter, declares what comes back and renders it, reading each field off the row by attribute.
rename(owner, {"pk": 1, "title": "Final"}) returns {"id": 1, "title": "Final"}.
For another user it raises NotPermitted,
and for a note that does not exist it returns None, because
dispatch reports a missing
row as kind="not_found" rather than raising: an HTTP view answers it with a
404, a command with an exit code, a tool with an error result.
Where to go next¶
- Declaring an operation: the two spec types, Parameters, the Validator contract and its dataclass adapter, Output and the Presenter.
- Dispatching: the six steps, results, grants, the async entry point, and pool seeds.
- Serving over HTTP: a spec as a Django view, answered as JSON: the arguments a request carries, the principal, and the status of each refusal.
- Arguments and refusals: the shape check, the error tree, flat transports, and the two error families.
- Relation writes: writing a row and its related rows in one operation.
- The registry: one named set of operations for several transports.
- The forms adapter: a Django form class,
ModelFormincluded, as the Validator. - The pydantic adapter: a pydantic model as the Validator and
the Presenter, with the
pydanticextra. - JSON Schema: the input and output schema a transport describes an operation with.
- API reference: every public name.