Resources & Endpoints¶
jero has exactly two route-defining shapes. Pick by whether the route is a REST collection or a one-off.
Resource — REST collections¶
A Resource is a class. Define any of the six CRUD methods; their names decide
the HTTP method and status. For path="/widgets":
| Method | HTTP | Path | Status |
|---|---|---|---|
create |
POST | /widgets |
201 |
read_many |
GET | /widgets |
200 |
read_one |
GET | /widgets/{widget_id} |
200 |
update_full |
PUT | /widgets/{widget_id} |
200 |
update_partial |
PATCH | /widgets/{widget_id} |
200 |
delete |
DELETE | /widgets/{widget_id} |
200 |
from msgspec import Struct
from jero import BaseApp, Resource
class WidgetIn(Struct):
name: str
class Widget(WidgetIn):
id: str
class WidgetPath(Struct):
widget_id: str
class WidgetResource(Resource, path="/widgets"):
async def create(self, json: WidgetIn) -> Widget: # POST /widgets
return Widget(id="widget-id", name=json.name)
async def read_many(self) -> list[Widget]: # GET /widgets
return [Widget(id="widget-id", name="gizmo")]
async def read_one(self, path: WidgetPath) -> Widget: # GET /widgets/{widget_id}
return Widget(id=path.widget_id, name="gizmo")
async def delete(self, path: WidgetPath) -> Widget: # DELETE /widgets/{widget_id}
return Widget(id=path.widget_id, name="gizmo")
class App(BaseApp):
async def wire(self) -> None:
self._include_resource(WidgetResource())
app = App()
The item id is the one path field the mount path doesn't name, appended as the last
segment. Its name is yours to choose, but it becomes the {slot} in the URL and the
OpenAPI spec, so name it for the resource (widget_id, not id). Item routes need
exactly one; create and read_many take none. Both are checked at startup.
Endpoint — single routes¶
An Endpoint is a class with bare verb methods (get / post / put / patch /
delete). There are no CRUD semantics: the method name is the verb, every verb
returns 200, and the path is exact. A different path is a different Endpoint.
from msgspec import Struct
from jero import BaseApp, Endpoint
class Health(Struct):
status: str
class HealthEndpoint(Endpoint, path="/healthz"):
async def get(self) -> Health: # GET /healthz
return Health(status="ok")
class App(BaseApp):
async def wire(self) -> None:
self._include_endpoint(HealthEndpoint())
app = App()
Use endpoints for health checks, webhooks, and legacy paths you must serve verbatim.
Actions on a resource¶
An "action" (converse on a conversation, submit on an order) usually doesn't need
an Endpoint — and reaching for one duplicates the resource's path template in a
second class. Model the action's result as a sub-resource instead, and the action
becomes an ordinary create:
from msgspec import Struct
from jero import BaseApp, Resource
class MessageIn(Struct):
text: str
class Message(MessageIn):
id: str
class ConversationPath(Struct):
conversation_id: str
# "converse" is a create on the conversation's messages
class MessageResource(Resource, path="/conversations/{conversation_id}/messages"):
async def create(self, path: ConversationPath, json: MessageIn) -> Message:
return Message(id="message-id", text=json.text)
class App(BaseApp):
async def wire(self) -> None:
self._include_resource(MessageResource())
app = App()
The result gets an id, a later GET, 201/Location semantics, and an OpenAPI home
under its parent. State changes (cancel, archive) are often an update_partial on a
status field. Use an Endpoint only when no noun works.
When no noun works, the Endpoint's path may end in a custom method: the last
segment's slot followed by :verb, the shape Google's AIP-136 uses
(POST /widgets/{widget_id}:duplicate). It sits beside the resource's own
/widgets/{widget_id} routes without clashing:
from msgspec import Struct
from jero import BaseApp, Endpoint, Resource
class Widget(Struct):
id: str
class WidgetPath(Struct):
widget_id: str
class WidgetResource(Resource, path="/widgets"):
async def read_one(self, path: WidgetPath) -> Widget: # GET /widgets/{widget_id}
return Widget(id=path.widget_id)
class WidgetDuplicateEndpoint(Endpoint, path="/widgets/{widget_id}:duplicate"):
async def post(self, path: WidgetPath) -> Widget: # POST /widgets/{widget_id}:duplicate
return Widget(id=f"{path.widget_id}-copy")
class App(BaseApp):
async def wire(self) -> None:
self._include_resource(WidgetResource())
self._include_endpoint(WidgetDuplicateEndpoint())
app = App()
The slot binds everything before the suffix, so POST /widgets/a:b:duplicate binds
widget_id="a:b". An action route is tried before a plain {widget_id} route on the same
verb, and a request whose suffix matches no action (or would leave the slot empty) falls
through to that plain route. Matching sees the path as the server decoded it, so
x%3Aduplicate reaches the action too. Reverse routing (Location.from_operation(...))
and the OpenAPI path both carry the suffix. Reversal builds the URL from the values it is
given, so on the same method an id ending in an action's verb reverses to a URL the action
answers: keep ids free of :, as AIP-122 asks. A plain route an action would answer entirely
is a WiringError: a literal /groups/{group_id}/all:go beside the action
/groups/{group_id}/{item_id}:go on the same method could never run. A
wholly literal last segment such as /widgets:batchGet needs none of this: it is matched
as written.
Path templates¶
A route declares its path on the class, at definition time. jero reads it once at
wiring — no path passed at the call site — so the class is the single source of
truth for its path, which is exactly what URL reversal
(Link / Location) and the OpenAPI generator
read off it.
The mount path is a template: static segments plus {slot} params in snake_case.
A handler binds the slots through a path Struct whose fields must cover every
slot:
from msgspec import Struct
from jero import BaseApp, Resource
class Item(Struct):
id: str
class CollectionPath(Struct):
collection_id: str
item_id: str
class ItemResource(Resource, path="/collections/{collection_id}/items"):
# GET /collections/{collection_id}/items/{item_id}
async def read_one(self, path: CollectionPath) -> Item:
return Item(id=path.item_id)
class App(BaseApp):
async def wire(self) -> None:
self._include_resource(ItemResource())
app = App()
Rules, all checked at startup with a precise WiringError:
- Every
{slot}in the mount path must be a field on thepathStruct. - Path Struct fields cannot have defaults — a URL segment is always present.
- Item routes have exactly one field beyond the slots (the id); every other route has none.
- When two templates on the same method could match one path, an action (
:verb) route wins, then the one with more literal segments (/groups/special/items/{item_id}over/groups/{group_id}/items/{item_id}), whatever order they were included in. Only between equally literal templates does inclusion order decide. - A
:verbaction suffix goes only on the last segment of anEndpointpath, right after its slot. The verb is letters, digits,-,.,_or~.
Path values that fail conversion to their field type return 404 — a malformed id doesn't identify a resource.
Registering them¶
Resources and endpoints are wired in BaseApp.wire:
class App(BaseApp):
async def wire(self) -> None:
self._include_resource(WidgetResource())
self._include_endpoint(HealthEndpoint())
Routing is pure dict lookup: static routes match exactly; templated routes are
bucketed by (method, segment-count) and matched on their static segments — no
regexes, no route-table scans, no ordering rules. All of it is resolved once, at
wiring time.
Metadata¶
Alongside the path, a route can declare OpenAPI metadata at class definition. meta
applies to every operation; meta_<operation> to one (meta_get, meta_create, …):
from msgspec import Struct
from jero import BaseApp, Endpoint, EndpointMeta, OperationMeta
class Widget(Struct):
id: str
class WidgetsEndpoint(
Endpoint,
path="/widgets",
meta=EndpointMeta(tags=["widgets"]), # all operations
meta_get=OperationMeta(operation_id="listWidgets"), # this operation
):
async def get(self) -> list[Widget]:
return [Widget(id="widget-id")]
class App(BaseApp):
async def wire(self) -> None:
self._include_endpoint(WidgetsEndpoint())
app = App()
All three types — EndpointMeta, ResourceMeta, OperationMeta — carry tags,
responses, and exceptions; operation_id, summary, and description live only on
OperationMeta, so they can't cascade to every operation. They don't affect routing;
they refine the auto-generated OpenAPI spec, which derives the rest from
your types (docstrings are never published — public prose is always explicit).
See Wiring & lifecycle for how resources get their dependencies.