🤖 op
amadan.net/rastrillo/rastrillo/op
What operations and both of their doors share: the refusals an operation
returns, the input types that remember whether a field was sent, and the
descriptor that names an operation for any transport. rastrillo generate writes one operation per action to gen/ops/<name>/;
JSON API says how to replace
one.
Refusals
type Invalid struct {
Fields map[string]string
AsSent bool
}
type NotFound struct{}
type Forbidden struct{ Reason string }
type Conflict struct{ Message string }
An operation returns one of these to refuse a call. All four are values,
so match them with errors.As:
var inv op.Invalid
if errors.As(err, &inv) {
// inv.Fields names what was wrong
}
Invalid.Fields is keyed by manifest field name, such as Title. The
page door puts each message beside its field, and the API door turns the
key into its wire name. When AsSent is true, the API and MCP report
the keys under the names the client sent. NotFound is the answer both
for a row that does not exist and for one that is not the caller's.
Forbidden.Reason is for your logs and never reaches the response.
Conflict.Message is safe to show.
Invalid.Error, NotFound.Error, Forbidden.Error and Conflict.Error
are for log lines: invalid: Amount, Title (the field names, sorted,
never the messages), not found, forbidden: <reason> and
conflict: <message>.
Check and DefaultPolicy
func Check(ctx context.Context, rc *rastrillo.Ctx, o rastrillo.Operation) error
func DefaultPolicy(ctx context.Context, a rastrillo.Actor, o rastrillo.Operation) error
Check is the one place both doors ask who may do what. Call it first in
any operation you write by hand. It asks, in order:
- A read-only token on a
Writeoperation is refused. - A scoped operation with no
Subjectis refused. - If
rc.Authorizeis set, its error is the answer. Returnnilto allow, orForbiddenorNotFoundto refuse.Checkreturns your error unchanged, and the door that called it answers it the way it answers the same error from an operation: the API by its errors table, a page with its 403 or 404 page. An error neither recognises is a 500. - Otherwise
DefaultPolicydecides.
The first two run before your policy, so no policy can let a read-only token write or let a caller with no subject reach a scoped row.
DefaultPolicy allows a scoped operation, because the owner filter keeps
the caller to their own rows. It allows an unscoped operation for a
signed-in person, an anonymous visitor and the app. It refuses an
unscoped operation for a token, because your pages may guard those tables
with middleware the API cannot see. To let tokens in, say so in your
Authorize, and call DefaultPolicy from it for everything else.
Opt and Some
type Opt[T any] struct {
Value T
Set bool
}
func Some[T any](v T) Opt[T]
Opt is a field a request may or may not carry. After decoding, Set is
true when the member was sent, whatever its value, so an explicit 0 is
present and a missing member is not. Use it for every field of a create
or update input: an update then changes only the fields the client sent,
and {} changes nothing.
Opt.UnmarshalJSONFrom refuses null. No manifest field is nullable, so
a null is a client bug and is reported on that field.
Opt.MarshalJSONTo writes the value, or null when unset. Some makes a
present Opt in Go code.
Money and Nullable
type Money struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
type Nullable[T any] struct {
Value T
Set bool
Null bool
}
Money is an amount on the wire: amount in the currency's minor unit
and currency as an ISO 4217 code. Leave currency out of a request to
use the manifest's default on a create, or to keep the stored currency on
an update. The operation checks the code; this package does not.
A required money field is Opt[Money]. An optional one is
Nullable[Money], which is Opt plus a Null flag. A JSON null sets
both Set and Null: the member was sent, and it asks to clear the
field. Nullable.UnmarshalJSONFrom is the only decoder here that accepts
null. Nullable.MarshalJSONTo writes null when unset or Null, and
the value otherwise.
Nullable.IsNullable is always true. It is the marker DecodeInput reads
to accept null for a field. Opt does not have it.
Money.UnmarshalJSONFrom reads {"amount": 1490, "currency": "USD"}.
amount is required and currency may be left out; any other member is
refused.
DecodeInput
func DecodeInput(data []byte, dst any) error
DecodeInput decodes one JSON object into dst, a pointer to a struct,
with the rules both doors share. api.Decode calls it on the body it
read, and every generated Descriptor.Invoke calls it on its
arguments, so the two cannot drift. It refuses an unknown member, a
member sent twice, a wrong type, or null outside a Nullable field.
It returns each as an Invalid keyed by the member's name as sent,
with AsSent set. A value that is not one JSON object is an Invalid
with no fields; the door says what to send instead.
EncodeCursor and DecodeCursor
func EncodeCursor(createdAt string, id int64) string
func DecodeCursor(s string) (createdAt string, id int64, ok bool)
A list's next_after is the last row's created_at and id, encoded as
one opaque string. DecodeCursor reverses it: "" is the first page,
and anything EncodeCursor could not have made answers ok == false,
so a hand-edited cursor is refused rather than read as some other place
in the list.
Descriptor
type Descriptor struct {
rastrillo.Operation
Exposed bool
Description string
Confirm string
Destructive bool
Idempotent bool
Input json.RawMessage
Result json.RawMessage
Invoke func(ctx context.Context, rc *rastrillo.Ctx, input json.RawMessage) (any, error)
}
Descriptor names one operation for any transport. The embedded
rastrillo.Operation gives its ID, resource, scope and access. Exposed
says whether the resource is on the API. Confirm is the sentence an
agent shows before a write. Input and Result are JSON Schema, and
both are required. Invoke decodes input with the API door's rules and
calls the operation.
Destructive means a call may change or remove what is already there;
false means it does not. Idempotent means two calls with the same
input leave the state one call would. MCP sends both to clients as
hints, and rastrillo generate sets them: see
MCP.
CheckFlags and FenceVerbs
func CheckFlags(descriptor Descriptor) error
var FenceVerbs = []string{
"cancel", "refund", "revoke", "reject", "remove", "withdraw",
"discard", "disconnect", "rotate", "regenerate", "replace", "delete",
"send", "resend", "purge",
}
CheckFlags refuses a read marked Destructive, and a write whose ID,
after its last ., starts with one of FenceVerbs but isn't
Destructive. So a verb that undoes or sends something can't be marked
safe by forgetting the flag. mcp.New runs it on every descriptor, and
the generator's tests run it on every generated one.
ResponseGuard
type ResponseGuard func(ctx context.Context, actor rastrillo.Actor, descriptor *Descriptor, input json.RawMessage, result any) error
A ResponseGuard checks whether the actor may still see the result,
such as whether each note in a search result is still in a team they
belong to. mcp.Config.Guard runs it with the fresh actor before the
headers and before each 64 KiB of an answer. descriptor is the
operation that ran, input the arguments as sent, and result the
value Invoke returned. For the tool list, descriptor and input are
nil and result is the []Descriptor that was listed.
A guard must have no side effects: it must never run the operation again or change state. It runs last, so put a slow check against another service before your own revision and link checks. Check the revision and links closest to the bytes going out.
Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.