🤖 api
amadan.net/rastrillo/rastrillo/api
The JSON door. This page covers the handler tree, the error shape, and the functions a hand-written handler uses to answer the way generated ones do. JSON API is the guide: how to mount the tree and write your policy.
New
func New(cfg Config, routes ...Route) (http.Handler, error)
type Config struct {
Verify Verifier
Ctx func(*http.Request) *rastrillo.Ctx
Authorize func(ctx context.Context, a rastrillo.Actor, o rastrillo.Operation) error
Logger *slog.Logger
MaxBody int64
Before []func(http.Handler) http.Handler
After []func(http.Handler) http.Handler
RequireAgentSession bool
Redact func(string) string
}
type Route struct {
Method, Pattern string
Op op.Descriptor
Handle func(ctx context.Context, rc *rastrillo.Ctx, w http.ResponseWriter, r *http.Request)
}
type Verifier interface {
Verify(ctx context.Context, r *http.Request) (rastrillo.Actor, error)
}
func (c Config) Gate() Gate
New builds the /api/v1 handler. Mount it at both /api/v1 and
/api/v1/.
Verify, Ctx and Authorize are required. To keep the default
policy, pass op.DefaultPolicy. New returns an error if one is
missing, or if a route:
- has no
Op.ID,Op.Input,Op.ResultorHandle; - has an
Op.Accessthat does not match its method: GET and HEAD must beRead, and POST, PUT, PATCH and DELETE must beWrite; - has the same method and pattern as another route.
Every answer is JSON with Cache-Control: no-store. A request goes
through these steps in order, and the first one that refuses answers:
- Routing: 404 for an unknown path, and 405 with
Allowfor a known path with the wrong method. A GET route also answers HEAD. Config.Before, such as a rate limiter.Verify: 401 or 503.- The agent headers: 422.
- With
RequireAgentSession, an agent's write with noAgent-Session: 422. - A read-only token on a
Writeroute: 403. Config.After, then the route'sHandle.
The answer is held until Handle returns, then sent, and with a
Rechecker it is checked again on the way out. See Gate.
Handle gets the Ctx that Config.Ctx builds, with Actor set to
the verified caller and Authorize set to Config.Authorize. Pattern
is the full path, such as /api/v1/bookmarks/{id}, and r.PathValue
works in Handle.
Before Handle runs, the tree logs one Info line to Config.Logger,
api: request, with method, path and the actor's
Actor.LogAttrs: actor, and agent_session and agent_harness
when they were sent. Every value but the method goes through
Config.Redact.
A Verifier names the caller. When it cannot, wrap ErrUnauthorized or
ErrUnavailable in the error it returns. The actor it returns must have
a Credential and an Access, or the answer is 500.
An agent may send Agent-Session and Agent-Harness. They are read only
for an agent's token, and copied to Actor.Session and Actor.Harness.
Each must be 200 bytes or fewer, with no control characters, or the
answer is 422. Set RequireAgentSession to refuse a write from an agent
token that sends no Agent-Session.
If a handler panics before writing, the answer is 500 with a ref. If
it panics after, the connection is closed. Either way the panic is
logged to Config.Logger under the ref, after Config.Redact.
Errors
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
Ref string `json:"ref,omitempty"`
}
Every refusal is {"error": {...}} with this body. Branch on Code,
not on Message: each code maps to one status, and the prose may change.
| Code | Status | When |
|---|---|---|
unauthorized |
401 | the token is missing or does not work |
forbidden |
403 | the token may not do this |
not_found |
404 | no such row, or not the caller's; no such path |
method_not_allowed |
405 | a known path, wrong method |
conflict |
409 | the write lost a race |
too_large |
413 | the body is over the cap |
unsupported_media_type |
415 | a write body without Content-Type: application/json |
invalid |
422 | a field was refused, and fields says which; or the body is not one JSON object |
response_too_large |
422 | the answer would be over the cap, 2 MiB on the tree |
rate_limited |
429 | a Config.Before limiter said no |
internal |
500 | something else went wrong; ref is in the log |
unavailable |
503 | the token store could not be asked |
Fields is keyed by wire name, such as title. Ref is set only on a
500. Quote it when you report the failure: the log line carries the same
one.
Gate
type Gate struct {
Verify Verifier
Logger *slog.Logger
MaxBody int64
MaxResponse int64
Before, After []func(http.Handler) http.Handler
RequireAgentSession bool
Redact func(string) string
}
func (g Gate) Validate() error
func (g Gate) Wrap(handler http.Handler) http.Handler
func (g Gate) Admit(actor rastrillo.Actor, operation rastrillo.Operation) error
Gate is the request pipeline the tree and mcp
share. Config.Gate copies it out of a tree's Config. Config has no
MaxResponse, so the tree's answers are capped at 2 MiB.
Validate reports a gate that would fail on its first request: no
Verify, or a nil Before or After. New and mcp.New call it.
Call it yourself before Wrap, which doesn't.
Wrap runs a handler behind the gate: Before, Verify, the agent
headers, After, panic recovery, redacted logging, and the answer held
and checked, with the actor on the request's context. It panics if a
Before or After returns nil. It doesn't call Admit; the handler
calls it once it knows the operation.
Admit is the per-operation half, in the tree's order. With
RequireAgentSession set, it refuses an agent's write that came with
no Agent-Session. Then it refuses a read-only token on anything but a
Read operation, as op.Forbidden.
MaxBody caps a request body, and zero or less means 1 MiB.
MaxResponse caps an answer, which is held whole until it has been
checked, and zero or less means 2 MiB. An answer over the cap is 422
response_too_large, refused before any recheck.
Rechecker and Disclosed
type Rechecker interface {
Recheck(ctx context.Context, request *http.Request, was rastrillo.Actor) (rastrillo.Actor, error)
}
func Disclosed(ctx context.Context, check func(ctx context.Context, fresh rastrillo.Actor) error)
When Verify is also a Rechecker, the gate sends each answer in
64 KiB chunks. Before the headers, and before each chunk, it:
- calls
Recheck, which must makeVerify's checks with no side effects: no quota charged, no last-used time written; - refuses the answer as
ErrUnauthorizedif the fresh actor'sSubject,Agent,CredentialorGrantdiffers from the original's. A grant is compared by its ID, issuers and audience, so itsTeamsandScopesmay change; - copies the original's
SessionandHarnessonto the fresh actor, because they come from the request's headers and aRecheckreturns them empty; - runs every check registered with
Disclosed, in order, with the fresh actor. The tree registersAdmitandop.Checkfor the route's operation.
Each round, and the write of the chunk after it, share one deadline: 5
seconds, or the request's own deadline if that is sooner. A callback
that returns after the deadline fails the round as ErrUnavailable,
whatever it returned. Each chunk's write deadline is set from its own
round, so the server's WriteTimeout doesn't bound a rechecked answer
as a whole. A client that reads slowly can hold a 2 MiB answer for 32
rounds.
A round that fails before the headers turns the answer into the
refusal: 401, 403 or 503. One that fails later cuts the connection, and
logs api: answer cut off. The answer carries a Content-Length, so
the client sees a short body. A transport that can't set a write
deadline, such as httptest.ResponseRecorder, gets 503 before anything
is sent, so test through httptest.NewServer.
The gate sets deadlines and flushes through its own writer, beneath any
Before middleware, so a Before that wraps the writer still works. A
wrapper that keeps bytes in its own buffer sends them when it closes,
after the last round.
Disclosed registers a check on this request's answer. Outside the
tree and Wrap it does nothing, and a nil check panics. Without a
Rechecker no check runs, but answers are still held and capped.
Refusal and IsAgentSessionRefusal
func Refusal(request *http.Request, err error) (int, Error)
func IsAgentSessionRefusal(err error) bool
Refusal is Refuse without the write: it logs the way Refuse does
and returns the status and the envelope. MCP puts the envelope in a
tool error, so both doors say the same thing about the same failure.
Admit's agent-session refusal is 422 invalid, with a message saying
to send Agent-Session with every write.
IsAgentSessionRefusal reports whether err is that refusal. MCP lists
such a write tool anyway, so the call itself tells the agent which
header to send.
Write, Encode, WriteError and Refuse
func Write(w http.ResponseWriter, status int, v any)
func Encode(value any) ([]byte, error)
func WriteError(w http.ResponseWriter, status int, e Error)
func Refuse(w http.ResponseWriter, r *http.Request, err error)
Write answers with v as JSON, with Content-Type: application/json
and Cache-Control: no-store. An empty list encodes as [], never
null. An encoding/json json.Number is written as the number it
holds, not as a string. Encode returns the bytes Write sends; MCP
encodes each tool result with it, so both endpoints write the same
value the same way. An empty json.Number, or one that isn't a JSON
number, can't be encoded. Encode returns an error. Write panics,
and the tree answers with a 500 and a ref. WriteError puts e under
"error" and writes it. Use it for a code Refuse cannot derive from
an error, such as rate_limited from a rate limiter.
Refuse answers an error. An op refusal gets its code and status:
op.Invalid is 422 with its fields re-keyed by FieldName,
op.NotFound 404, op.Forbidden 403, op.Conflict 409 with
Conflict.Message as the message. An op.Invalid with AsSent set
keeps its field names as the client sent them; op.DecodeInput sets
it. ErrUnauthorized is 401 with
WWW-Authenticate: Bearer, and ErrUnavailable is 503. Any other error
is a 500: the error text goes to the log under a fresh ref, and the
response carries only the ref. op.Forbidden.Reason is logged and never
sent.
var ErrUnauthorized = errors.New("rastrillo/api: unauthorized")
var ErrUnavailable = errors.New("rastrillo/api: credential store unavailable")
A Verifier wraps one of these when it cannot name an actor. Keep them
distinct: a client retries a 503 and gives up on a 401.
Decode, NoBody and WithMaxBody
func Decode(w http.ResponseWriter, r *http.Request, dst any) bool
func NoBody(w http.ResponseWriter, r *http.Request) bool
func WithMaxBody(r *http.Request, n int64) *http.Request
Decode reads the request body into dst, which must be a non-nil
pointer. If it returns false, it has already answered: return at once
and do not read dst. It checks, in this order:
Content-Typemust beapplication/json, or the answer is 415. Acharsetparameter is fine.- The body must fit the cap, or the answer is 413. The whole body is read first, so a body that is too large is 413 even if it is also broken.
- The body must be one JSON object, or the answer is 422 with no
fields. - Each member must be one
dsthas, in the same case, sent once, of the right type, and notnull. If not, the answer is 422, andfieldsnames the member.
null is allowed only for an op.Nullable field, such as optional
money, where it clears the value. It is refused everywhere else,
including inside a Nullable value.
Call NoBody first in a handler that takes no input, such as a DELETE.
Any body at all is 422, whatever its Content-Type.
The cap is 1 MiB. The tree sets it from Config.MaxBody with
WithMaxBody. Call WithMaxBody yourself only outside the tree or in a
test. An n of 0 or less keeps 1 MiB.
FieldName
func FieldName(manifestName string) string
The wire name of a manifest field: Title is title, MaxPerOrder is
max_per_order, IPAddress is ip_address. Use it when you build an
op.Invalid by hand and want to know what the client will see.
WithLogger and WithRedact
func WithLogger(r *http.Request, l *slog.Logger) *http.Request
func WithRedact(r *http.Request, redact func(string) string) *http.Request
Refuse reads its logger and redactor from the request. The tree sets
both from Config on every request; set them yourself only in a test
that reads the log line. Without them, Refuse logs to slog.Default()
and redacts nothing.
Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.