Capabilities¶
A capability is the unit of pluggability in jmaple. It's a single Python
object — an instance of Capability — that
declares:
- A URN (
urn:vendor:thing). - A function returning the properties advertised on the JMAP session resource for this capability.
- A function returning per-account properties (visible in
accountCapabilities). - Zero or more data types (each with a SQLAlchemy model).
- Zero or more method handlers (
<Type>/get,<Type>/set, …). - An optional Pydantic settings model.
You almost never construct a Capability directly. Use one of the declarative
helpers:
CrudCapability — the common case¶
CrudCapability subclasses get six standard
methods (get, changes, set, query, copy, queryChanges) generated
from a handful of class attributes.
Required:
| Attribute | Purpose |
|---|---|
urn |
The capability URN, advertised on /.well-known/jmap |
data_type_name |
The wire name of your data type, e.g. "Todo" |
model |
Your SQLAlchemy model class |
schema_class |
Pydantic wire schema for the object |
create_schema_class |
Pydantic schema for create payloads |
Optional:
| Attribute | Default | Purpose |
|---|---|---|
filter_schema_class |
None |
Type-validates the filter arg of <Type>/query |
enabled_methods |
all six | frozenset of method names to register |
allowed_update_properties |
create-schema fields | Which fields <Type>/set update accepts |
sortable_properties |
model column names | Which properties <Type>/query sort accepts |
Hook methods to override:
filter_clauses(filter_, account_id)— convert the JMAP filter object to SQLAlchemy WHERE clauses.to_schema(instance)— convert a model row to its Pydantic wire shape. Default usesmodel_validate(instance, from_attributes=True).create_model(payload, account_id, obj_id)— build a model instance from acreatepayload. Default unpacks the schema fields.clone_model(source, payload, account_id, obj_id)— build a copied row for<Type>/copy. Default copies every non-identity column.get_session_properties(settings)/get_account_properties(account)— control what shows up under your URN on the session resource.get_custom_methods()— return additional non-CRUD method handlers under this capability's URN.
BaseCapability — for non-CRUD shapes¶
For session-only capabilities (urn:ietf:params:jmap:websocket is an
example), or when you want full manual control over method handlers, subclass
BaseCapability and override
get_methods() to return a list of MethodHandler instances directly.
How jmaple finds your capability¶
Registration is programmatic: plugin authors call
register_capability(my_cap) from their own code at app boot. There is no
entry-point discovery, no JMAPLE_ENABLED_CAPABILITIES env var, and no
runtime gating by URN. The import graph is the source of truth.
The framework's bundled capabilities (core, websocket, blob, notes) are
registered automatically by jmaple.capabilities.registry.discover() on
first use. Your code adds to that set:
# my_app/app.py — your ASGI entry point
from jmaple.app import create_app
from jmaple.capabilities import register_capability
from my_app.capability import capability
register_capability(capability)
app = create_app()
Then run uvicorn my_app.app:app.
For management commands, write a CLI wrapper that does the same registration and re-exposes jmaple's Typer app:
# my_app/cli.py
from jmaple.capabilities import register_capability
from jmaple.cli import app
from my_app.capability import capability
register_capability(capability)
if __name__ == "__main__":
app()
Point pyproject.toml's jmaple script at it:
uv run jmaple … now imports your wrapper first, so registration runs
before any command dispatches.
Capability.as_capability() materialises a Capability instance from a
declarative subclass — call it once at module scope:
How the dispatcher knows which method to run¶
When a JMAP request arrives:
- The dispatcher validates that every URN in
usingis registered. - For each method call in
methodCalls, it looks up the method name in the registry (Foo/barmaps to oneMethodHandler). - If the method belongs to a capability that isn't in the request's
using, the call fails withunknownMethod(RFC 8620 §3.3). - The handler is invoked with the validated args and a per-invocation
MethodContext.
The dispatcher commits the SQLAlchemy session once at the end of the request, or rolls back if any handler raises an unhandled exception.