Resources & callbacks¶
A resource is a plain class. Every callback is async def callback(self, ctx) ->
… with a correct default; you override only what your resource needs. The
resource instance holds only its wired collaborators (a store, an authenticator) —
per-request state lives on ctx, never on the shared instance.
Declarations vs. callbacks¶
asgimachine transcribes the graph faithfully but breaks from webmachine's "everything is a callback" convention in one deliberate way:
Static resource shape is a declaration; per-request behavior is a callback. A thing that can't legitimately vary per request is a class attribute — the source of truth and the schema anchor — not something computed each time.
So the following are declarations (class attributes):
| Declaration | Meaning |
|---|---|
ALLOWED_METHODS: frozenset[str] |
permitted methods → 405 + Allow |
PRODUCES: tuple[str, ...] |
offered media types → 406 |
CONSUMES: tuple[str, ...] |
accepted request media types (writes) |
MAX_BODY_BYTES: int |
request-body cap → 413 |
ERROR_PRODUCES: tuple[str, ...] |
error-body media types (RFC 9457) |
context_class: type[Ctx] |
the Ctx subclass the core constructs |
Where an override escape hatch is still worth keeping, the graph reads the
declaration through a thin callback that defaults to it — e.g. allowed_methods(ctx)
returns ALLOWED_METHODS unless you override it. The common case is a one-line
class attribute; per-request variation stays possible; the schema reads the
attribute statically.
The callbacks you reach for¶
| Callback | Controls | Default outcome |
|---|---|---|
service_available |
availability | 503 when False |
is_authorized |
authentication | 401 (+ WWW-Authenticate if a str) |
forbidden |
authorization | 403 |
resource_exists |
existence | 404 / the create/redirect branch |
generate_etag / last_modified |
validators | 304 / 412 on conditionals |
represent |
the GET/HEAD body | encoded per PRODUCES |
apply |
the write body | 201/200/204; 415/400 on a bad body |
is_conflict |
write conflict | 409 |
cache_control / expires |
caching | emitted on cacheable responses |
lifespan |
per-request setup/teardown | see Lifespan |
The full surface is in the API reference.
Typed per-request state: subclassing Ctx¶
Base Ctx is deliberately minimal and domain-agnostic
— the request, the decision trace, the negotiated media type, the codec registry,
and an extra bag. Domain state (a principal, the loaded entity) goes in a Ctx
subclass. A resource is generic over its context type and names the subclass via
context_class, which the core constructs per request:
from dataclasses import dataclass, field
from asgimachine.resource import Ctx, Resource
@dataclass(slots=True)
class AccountsCtx(Ctx): # typed per-request state
user: User | None = None
accounts: list[Account] = field(default_factory=list)
class AccountsResource(Resource[AccountsCtx]):
ALLOWED_METHODS = frozenset({"GET", "HEAD"})
context_class = AccountsCtx
async def is_authorized(self, ctx: AccountsCtx) -> bool | str:
user = await self._authenticate(ctx)
if user is None:
return "Bearer" # -> 401 WWW-Authenticate: Bearer
ctx.user = user
return True
async def resource_exists(self, ctx: AccountsCtx) -> bool:
assert ctx.user is not None # set by is_authorized, which runs first
ctx.accounts = await self._retrieve(ctx.user.id)
return True
async def generate_etag(self, ctx: AccountsCtx) -> str | None:
return f'W/"accounts-{ctx.user.id}-{len(ctx.accounts)}"'
async def represent(self, ctx: AccountsCtx) -> object:
return {"data": [asdict(a) for a in ctx.accounts]}
Plain Resource (no type parameter) just uses base Ctx.
No dependency injection¶
Collaborators are constructor arguments, wired at a composition root (your app-builder function). Tests pass fakes to the constructor. There is no DI container, no override registry, no request-scoped resolver — wiring is explicit and lexical.