Documentation
¶
Overview ¶
Package rest mounts a schema-declared resource on a Huma API.
One generic function serves every resource. Resource[T, C, U] instantiates the same handlers for each model, and the OpenAPI document is still precise per resource, because the operation's parameters are built from the model's capabilities rather than from a Go struct. A resource that declares three filterable columns documents exactly three filter parameters, with the operators its column types accept.
That is what makes the compositional filter grammar describable: `?age=gte.18` is not a fixed parameter set, but the *columns* are fixed, and enumerating one parameter per filterable column is both precise and finite.
srv := rest.NewServer(rest.Config{Title: "Blog", Version: "1.0.0"})
blog.Register(srv.API, db) // generated: one Resource call per table
http.ListenAndServe(":8080", srv.Handler)
NewServer is the batteries-included path: a huma.API on net/http, with the OpenAPI document and docs page served for you, and no third-party router. Under it, each exposed table is one rest.Resource call, generated into rest_gen.go:
rest.Must(rest.Resource[blog.Post, blog.PostCreate, blog.PostPatch](srv.API, db, rest.Options{
Path: "/posts",
Ops: rest.CRUD | rest.OpList,
}))
NewServer is a convenience over a seam, not a replacement for it: Resource and the generated Register take a huma.API, so an application that wants chi, gin or echo — for that router's middleware — builds the API itself with the matching adapter (humachi.New(router, ...)) and passes it instead. The choice of router stays the application's. Nothing here imports the schema package: exposure reaches the runtime as an Options value, the way capabilities reach it as struct tags.
Reads are hooked ¶
Every read goes through sqlb.Query[T], so a BeforeQuery hook registered on T applies to the REST surface too. Tenant scoping is therefore a startup registration rather than something each handler has to remember. This is the reason registration is generic over T instead of reflective: hooks are keyed by type, and a reflective dispatcher could not run them.
Index ¶
- Constants
- Variables
- func Action[T, In any](api huma.API, db sqlb.Executor, opts Options, spec ActionSpec, ...) error
- func CollectionAction[In any](api huma.API, db sqlb.Executor, opts Options, spec ActionSpec, ...) error
- func Must(err error)
- func Resource[T any, C CreateBody[T], U UpdateBody](api huma.API, db sqlb.Executor, opts Options) error
- type ActionSpec
- type Config
- type CreateBody
- type None
- type Op
- type Options
- type Page
- type Problem
- type ProblemDetail
- type Server
- type UpdateBody
Constants ¶
const CRUD = OpCreate | OpRead | OpUpdate | OpDelete
CRUD is the conventional single-row operation set. Combine it with OpList for a fully exposed collection.
Variables ¶
var ErrNoTransaction = errors.New(
"rest: this action needs the transaction, and the resource runs its writes under autocommit (Options.DisableTransactions)")
ErrNoTransaction is what a verb returns when it needs the unit of work and the resource does not open one.
It is reachable only under Options.DisableTransactions, and it is worth a named error rather than a local one because the alternative is worse than an error: a verb that shrugs and writes its side effect anyway leaves half of a transition durable, which is the failure the transaction exists to prevent.
Functions ¶
func Action ¶ added in v0.5.0
func Action[T, In any](api huma.API, db sqlb.Executor, opts Options, spec ActionSpec, do func(context.Context, *T, In) error) error
Action registers a verb on one row of T.
The envelope fetches the row, hands it to do inside a transaction, persists spec.Writes, and answers 200 with the row. do reports failure by returning an error; a *Problem is answered with its own status, which is how a verb says "cannot complete an archived task" is a 409 rather than a 500.
func CollectionAction ¶ added in v0.5.0
func CollectionAction[In any](api huma.API, db sqlb.Executor, opts Options, spec ActionSpec, do func(context.Context, In) error) error
CollectionAction registers a verb on the collection rather than on a row.
There is no row to fetch, so do receives only the body and the response is a 204. Note what is absent along with the fetch: no BeforeQuery runs, so a declared scope obliges nothing here and confining the statements this verb issues is the verb's own job — the position sqlb.Query in application code is already in (ADR-0030).
func Must ¶
func Must(err error)
Must panics if err is non-nil. Generated registration code uses it, since a resource that cannot be mounted is a startup failure either way.
func Resource ¶
func Resource[T any, C CreateBody[T], U UpdateBody](api huma.API, db sqlb.Executor, opts Options) error
Resource registers the exposed operations for model T on api.
T is the row type, C the create body and U the update body. A resource that exposes neither create nor update passes rest.None[T] for both; the types are still instantiated, but Huma never sees them because the operations are not registered, so they stay out of the OpenAPI components.
Registration is the startup path, so failures are returned rather than panicked: a mistake here should name the resource that caused it.
Types ¶
type ActionSpec ¶ added in v0.5.0
type ActionSpec struct {
// Name is the verb, used in the operation ID: "complete" gives
// complete-task.
Name string
// Path is the full route, resource path included: "/tasks/{id}/complete".
// A path with no "{id}" is a collection action.
Path string
// Field is the name of this action's field on the generated Actions
// struct, so that a nil func can be reported as the thing the author has
// to go and set.
Field string
// Summary and Description document the operation.
Summary string
Description string
// Writes names the columns the envelope persists after the verb returns.
// Empty means the verb writes nothing through the envelope — it may still
// write through the transaction, which it has.
Writes []string
// HasBody reports whether the action declared any body properties.
//
// The input type is generated either way, so that adding the first property
// later does not change the shape of the func the application wrote. This
// is what decides whether the *operation* reads a request body, because an
// empty struct registered as a required body would make
// POST /tasks/{id}/complete refuse a request that carries nothing — which
// is the commonest verb there is.
HasBody bool
}
ActionSpec describes one action to the runtime.
It restates what schema.Action declared, and codegen writes it from that declaration — the same arrangement Options has with schema.REST, and for the same reason: nothing on the request path imports the schema package.
type Config ¶
type Config struct {
// Title and Version identify the API in its OpenAPI document. They default
// to "API" and "1.0.0".
Title string
Version string
// Description is the document's prose summary. Optional.
Description string
// Customize, if set, receives the huma.Config after the fields above are
// applied and before the API is built. It is where a security scheme, a
// server URL, or a non-default docs path goes — anything this struct does
// not name — and it may override what the fields above set.
Customize func(*huma.Config)
}
Config describes the default REST server: the identity its OpenAPI document carries, and an escape hatch for anything the named fields do not cover.
The zero value is usable — Title and Version default — so the smallest server is rest.NewServer(rest.Config{}).
type CreateBody ¶
type CreateBody[T any] interface { // Row builds the row to insert. Returning an error rejects the request as // a 422, which is where cross-field validation belongs. Row() (*T, error) }
CreateBody is what a POST body must be able to do: turn itself into a row.
The conversion is the body type's job rather than the handler's because only the body knows which of its fields were meant for which column. Codegen emits one of these per creatable resource; a hand-written model supplies its own.
type None ¶
type None[T any] struct{}
None stands in for a body type on a resource that does not expose the corresponding operation. Its methods are never called, because the operation is never registered.
type Op ¶
type Op uint8
Op is a bitmask of the operations a resource exposes.
It mirrors schema.Op deliberately rather than importing it. Nothing on the request path may import the schema package — that is what keeps the runtime usable without the DSL — so the exposure decision crosses the line as a value, not as a type.
type Options ¶
type Options struct {
// Path is the collection path, e.g. "/posts". Required.
Path string
// Ops is the set of exposed operations. Required: a resource exposing
// nothing is a mistake rather than a way to hide one.
Ops Op
// Name is the singular resource name used in operation IDs and summaries,
// e.g. "post" gives list-posts and get-post. Defaults to the path with its
// leading slash removed.
Name string
// Tag groups the operations in the OpenAPI document. Defaults to Name.
Tag string
// Description documents the resource. It comes from the table's comment.
Description string
// Pagination and filter limits. Zero means the filter package's default.
// MaxPageSize is a hard ceiling, not a hint: a client asking for more gets
// the maximum rather than an error.
DefaultPageSize int
MaxPageSize int
MaxFilters int
MaxSortTerms int
// Expandable lists the relation names ?expand may name. Each must be a
// relation the model declares — a `expands=` field beside an `expand`
// column — and is checked at startup, because at request time an unknown
// name would parse cleanly and answer 200 with the relation missing.
//
// Leaving it empty offers no ?expand at all, which is the right default: a
// join is a cost, and a relation the schema happens to declare is not the
// same thing as one this resource wants to serve.
Expandable []string
// DisableSearch rejects ?search even when columns are searchable.
DisableSearch bool
// DisableTransactions runs generated writes under autocommit.
//
// The default — wrapping each create, update and delete in a transaction —
// is what makes sqlb.AfterCommit reachable from a generated write. Without
// it there is no commit for a hook to be after, so a documented feature is
// unreachable from the writes most applications actually issue
// ([ADR-0021](../docs/adr/0021-hooks-receive-an-event.md)).
//
// The cost is a BEGIN/COMMIT round trip per write, and a server-side
// connection held for longer. Behind PgBouncer in transaction pooling mode
// that is a change in occupancy rather than only in latency
// ([ADR-0019](../docs/adr/0019-pgbouncer-in-the-path.md)), so this exists
// for anyone who measures it and decides against.
//
// Turning it on silently stops any AfterCommit callback the resource's
// hooks register. Read that as the reason it is phrased as a disable rather
// than as an enable: the safe value is the zero value.
DisableTransactions bool
// Security is the OpenAPI security requirement every operation of this
// resource carries — the same shape huma.Operation.Security takes, so it is
// a list of alternatives and each alternative names schemes and their
// scopes:
//
// Security: []map[string][]string{{"bearerAuth": {}}}
//
// It documents; it does not enforce. Authentication is middleware on the
// router, and it runs whether or not this is set — leaving it empty produces
// operations that are protected and do not say so, which is what every
// consumer of the document has to guess about.
//
// The generated clients do not read this, and that is not an oversight: they
// are generated from the schema rather than from the document, and they take
// the credential from the transport the consuming project supplies. What
// this is for is /docs, an agent reading the spec, and anything else driven
// by the document.
//
// The scheme itself is declared once on the API, not here:
//
// api.OpenAPI().Components.SecuritySchemes = map[string]*huma.SecurityScheme{
// "bearerAuth": {Type: "http", Scheme: "bearer", BearerFormat: "JWT"},
// }
Security []map[string][]string
}
Options describes how one resource is exposed. It restates what the schema declared in schema.REST, and codegen writes it from that declaration.
type Page ¶
type Page[T any] struct { Items []row[T] `json:"items" doc:"The rows on this page"` Page int `json:"page" doc:"1-based page number"` PerPage int `json:"per_page" doc:"Rows requested per page, after the resource's ceiling was applied"` HasMore bool `json:"has_more" doc:"Whether a further page exists"` NextCursor *string `json:"next_cursor,omitempty" doc:"Position to resume from; pass it back as ?cursor="` Total *int64 `json:"total,omitempty" doc:"Total matching rows; present only when ?count=exact was given"` }
Page is the body of a list response.
Total is absent unless the request asked for it with `?count=exact`, because counting is a second query over the same predicate and most clients only need to know whether to fetch again. HasMore answers that for the price of reading one extra row. NextCursor is the position to resume from, and is the paging a client should prefer: it costs the same at any depth and does not skip or repeat rows when the table is written to mid-walk. It is present whenever there is a next page and the model has a primary key to break ties with, including on a request that paged by offset — so a client can switch to cursors without a flag.
type Problem ¶
type Problem struct {
// Type is the RFC 9457 problem type URI.
Type string `json:"type,omitempty" doc:"A URI reference identifying the problem type"`
// Title is the short, human-readable summary of the problem.
Title string `json:"title,omitempty" doc:"Short, human-readable summary of the problem"`
// Status is the HTTP status code.
Status int `json:"status,omitempty" doc:"HTTP status code"`
// Detail explains this specific occurrence.
Detail string `json:"detail,omitempty" doc:"Explanation specific to this occurrence"`
// Errors lists every problem found, not just the first, so a malformed
// request takes one round trip to fix rather than one per mistake.
Errors []*ProblemDetail `json:"errors,omitempty" doc:"Every problem found with the request"`
}
Problem is the body of every rejection this package produces.
It is RFC 9457 shaped, like Huma's own, so a generated client sees one error type across the whole API. The one addition is `allowed` on each detail, which carries what the caller could have asked for instead — the substance of ADR-0011. Huma's own ErrorDetail has no room for it, and flattening the allow-list into the message would leave a client parsing prose to recover.
A handler returning this value has it marshalled directly, because it satisfies huma.StatusError.
func (*Problem) ContentType ¶
ContentType marks the body as an RFC 9457 problem document.
type ProblemDetail ¶
type ProblemDetail struct {
// Message says what was wrong.
Message string `json:"message" doc:"What was wrong"`
// Location is a path-like pointer to the offending input, e.g.
// `query.sort` or `body.title`.
Location string `json:"location,omitempty" doc:"Where the problem is, e.g. 'query.sort'"`
// Value is the rejected value, echoed back.
Value any `json:"value,omitempty" doc:"The rejected value"`
// Allowed lists what would have been accepted instead, where there is a
// finite set. Hidden columns never appear here: the diagnostic must not
// become an oracle for what a resource is concealing.
Allowed []string `json:"allowed,omitempty" doc:"What would have been accepted instead"`
}
ProblemDetail is one rejected parameter or field.
type Server ¶
type Server struct {
// API is what resources mount on. Pass it to a generated Register, or
// register hand-written operations on it directly.
API huma.API
// Mux is the underlying mux. Mount application routes on it — a health
// check, authentication — alongside the generated ones.
Mux *http.ServeMux
// Handler is Mux. Serve it, or wrap it with application middleware first.
Handler http.Handler
}
Server is a ready-to-serve REST API: a huma.API mounted on a net/http mux, with the OpenAPI document and its docs page already served by huma at /openapi.json, /openapi.yaml and /docs.
It carries no router beyond the standard library's ServeMux, so an application that wants generated CRUD and nothing more needs no third-party router. Build one with NewServer; the zero value is not useful.
func NewServer ¶
NewServer builds the default REST server: a huma.API on net/http, whose OpenAPI document and docs page huma serves without further wiring.
It is the batteries-included front door to the same surface Resource mounts. An application that needs a different router, a different huma adapter, or its own huma.Config builds the huma.API itself and calls Resource — or the generated Register — directly. This constructor is a convenience over that seam, not a replacement for it: everything it returns is a plain huma.API and a plain ServeMux the application still owns.
type UpdateBody ¶
type UpdateBody interface {
// Changes maps column name to new value for the fields the request
// carried. An empty map is rejected as a 400 rather than run as a no-op
// update, because it almost always means the client sent the wrong shape.
Changes() (map[string]any, error)
}
UpdateBody is what a PATCH body must be able to do: report which columns the request actually named.
A typed struct cannot distinguish "absent" from "zero", which is the whole difficulty of PATCH, so the body type reports the change set explicitly. Codegen emits fields as pointers and returns only the non-nil ones.