rest

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 15 Imported by: 0

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

CRUD is the conventional single-row operation set. Combine it with OpList for a fully exposed collection.

Variables

This section is empty.

Functions

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 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.

func (None[T]) Changes

func (None[T]) Changes() (map[string]any, error)

Changes satisfies UpdateBody and always fails, since a resource using None does not expose update.

func (None[T]) Row

func (None[T]) Row() (*T, error)

Row satisfies CreateBody and always fails, since a resource using None does not expose create.

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.

const (
	OpCreate Op = 1 << iota
	OpRead      // GET /resource/{id}
	OpUpdate
	OpDelete
	OpList // GET /resource with filter, sort, search, pagination
)

func (Op) Has

func (o Op) Has(op Op) bool

Has reports whether the mask contains op.

func (Op) String

func (o Op) String() string

String renders the mask for diagnostics.

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

func (e *Problem) ContentType(string) string

ContentType marks the body as an RFC 9457 problem document.

func (*Problem) Error

func (e *Problem) Error() string

Error satisfies the error interface.

func (*Problem) GetStatus

func (e *Problem) GetStatus() int

GetStatus satisfies huma.StatusError, which is what makes Huma write this model rather than converting it to its own.

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

func NewServer(cfg Config) *Server

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL