rest

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Aug 1, 2026 License: MIT Imports: 19 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

View Source
var ErrBrokerClosed = errors.New("rest: the broker is closed")

ErrBrokerClosed reports a subscription to a Broker that has been closed.

View Source
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 Events added in v0.6.0

func Events(api huma.API, opts EventsOptions) error

Events mounts the change-feed endpoint on api.

The stream is documented in the OpenAPI document like every other operation — one `oneOf` per event type, with the payload schema of each — because it is registered through huma rather than as a hand-rolled handler on the mux. A consumer generating a client from the document therefore learns the event shapes rather than being told the response is text.

broker := rest.NewBroker(rest.BrokerOptions{})
rest.Must(rest.PublishChanges[blog.Post](broker))
rest.Must(rest.Events(srv.API, rest.EventsOptions{Source: broker}))

Registration is the startup path, so failures are returned rather than panicked, as with Resource.

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 PublishChanges added in v0.6.0

func PublishChanges[T any](r *sqlb.Registry, p Publisher) error

PublishChanges makes every write of T announce itself to p.

It registers hooks rather than wrapping the REST handlers, and that is the design rather than an implementation detail. Hooks are keyed by type and run inside the mutation, so one registration covers the generated CRUD handlers, the generated actions, and the application's own sqlb writes alike — the same reason a BeforeQuery hook is what scopes reads instead of each handler remembering to. A change feed fed only by the REST layer would go quiet for exactly the writes most likely to matter: the background job, the migration, the admin script.

Wire it once at startup, beside the resources:

broker := rest.NewBroker(rest.BrokerOptions{})
rest.Must(rest.PublishChanges[blog.Post](broker))
rest.Must(rest.Events(srv.API, rest.EventsOptions{Source: broker}))

When the event is published

After the transaction commits, through sqlb.AfterCommit. Announcing from inside the mutation would publish changes that then rolled back, and a client refetching on one of those would see the row unchanged and cache the contradiction.

That requires the write to be in a transaction, which generated writes are by default (Options.DisableTransactions is what turns it off). Under autocommit there is no commit left to be after — the statement is already durable when the hook runs — so the event is published immediately. The distinction is real but not visible to a subscriber.

Multi-tenancy

When the model declares a `scope` column (ADR-0030), each event carries that column's value in Event.Scope — off the wire, for EventsOptions.Filter to compare against the subscriber's tenant. Without it a filter has nothing to decide on, because an invalidation names a row and not the tenant that owns it.

A soft delete is an UPDATE, so it carries both the key and the scope. A hard delete carries neither; see below.

What a delete announces

A table, with no key. sqlb's AfterDelete hook receives the number of rows removed rather than the rows themselves, so the key is not available to publish. A subscriber reads the keyless event as "invalidate this collection", which is what a delete requires of it regardless: the row is gone and the list it was in has changed.

Which registry

r is the registry the announcing hooks are registered into, and it must be the one the handle doing the writing resolves against — the registry passed to sqlb.DB.WithHooks. Publishing into a registry no handle carries registers hooks nothing will ever run, which looks exactly like a working invalidation feed that never emits.

This used to default to a process-wide registry, with the registry-taking form under a longer name. Removing that default was ADR-0047.

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 Broker added in v0.6.0

type Broker struct {
	// contains filtered or unexported fields
}

Broker is an in-process Source: writes publish to it, and the subscribers connected to *this process* receive them.

What it is not

It is not the change feed ADR-0012 describes, and the difference is worth stating before anything is built on it.

  • **At-most-once.** Publication happens after the transaction commits, in the same process. A crash between the commit and the fan-out loses the event, and no client learns the row changed.
  • **One replica.** A Broker serves the subscribers holding a connection to the process it lives in. Behind two replicas, a write served by one is invisible to everyone connected to the other.

Both are consequences of the event being held in memory rather than written to the database in the same transaction as the change, which is exactly what the outbox in ADR-0012 fixes. Until it exists, this is a real feature for a single-replica deployment and a trap for a horizontally scaled one, so it says so here rather than in a changelog.

What it does do carefully is fail loudly. A subscriber that falls behind is disconnected rather than quietly skipped, a gap that cannot be replayed arrives as a Reset rather than as silence, and both converge on a client that refetches. The failure mode this avoids — a client that never learns a row changed and displays it forever — is the one that looks like everything is working.

func NewBroker added in v0.6.0

func NewBroker(opts BrokerOptions) *Broker

NewBroker returns an in-process event source. The zero BrokerOptions is usable.

func (*Broker) Close added in v0.6.0

func (b *Broker) Close()

Close disconnects every subscriber and refuses further subscriptions. Publishing to a closed Broker is a no-op rather than an error, so a shutdown racing an in-flight after-commit callback does not turn into a logged failure on a write that succeeded.

func (*Broker) Publish added in v0.6.0

func (b *Broker) Publish(events ...Event)

Publish numbers each event and fans it out to every current subscriber.

It does not block and does not report failure, which is what lets it be called from an after-commit callback: the write is already durable by then, and a change feed that could fail a committed request would be worse than one that drops an event and says so to the client that missed it.

func (*Broker) Subscribe added in v0.6.0

func (b *Broker) Subscribe(ctx context.Context, since uint64) (<-chan Delivery, error)

Subscribe implements Source.

func (*Broker) Subscribers added in v0.6.0

func (b *Broker) Subscribers() int

Subscribers reports how many streams are connected. It exists for tests and for a metric; nothing on the request path reads it.

type BrokerOptions added in v0.6.0

type BrokerOptions struct {
	// History is how many recent events are kept so that a client reconnecting
	// with Last-Event-ID can be caught up rather than told to refetch.
	// Defaults to 256. Zero after defaulting is not reachable; use a negative
	// value to disable replay, which makes every reconnection a reset.
	History int

	// Buffer is how many events may queue for one subscriber before the Broker
	// gives up on it and closes its channel.
	//
	// Defaults to 256, and is raised to History+1 if it is set lower, because
	// a subscription hands its replay to the channel before returning and a
	// buffer that could not hold the replay would deadlock the publisher.
	Buffer int
}

BrokerOptions configures the in-process Broker.

type Change added in v0.6.0

type Change string

Change names what happened to a row.

It is deliberately not Op. Op is a bitmask of the operations a resource *exposes*, and a change is one thing that happened to one row — a mask whose String is "create|update" would be meaningful as exposure and meaningless here. Only three of Op's five members can ever reach a change feed anyway: reading a row does not change it.

const (
	Created Change = "create"
	Updated Change = "update"
	Deleted Change = "delete"
)

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 Delivery added in v0.6.0

type Delivery struct {
	// ID is the position. It increases by one per event within a Source and is
	// what a reconnecting client asks to resume after.
	ID uint64

	// Event is the change. It is the zero Event when Reset is set.
	Event Event

	// Reset, when non-nil, replaces the events the subscriber missed with an
	// instruction to refetch.
	Reset *Reset
}

Delivery is one event as a Source hands it to a subscriber, carrying the stream position the client echoes back in Last-Event-ID.

type Event added in v0.6.0

type Event struct {
	// Table is the SQL table name, matching what the generated client's
	// keysByTable is keyed by.
	Table string `json:"table" doc:"SQL table the change happened in"`

	// Key is the primary key of the row, rendered as a string the way the URL
	// renders it, so that it concatenates onto the resource path.
	//
	// It is empty when the change was not attributable to one row, which today
	// means every delete: sqlb's AfterDelete hook receives a count rather than
	// the rows it removed. An empty Key means "something in this table
	// changed" — invalidate the collection. That is not a degradation for a
	// delete, because a delete changes the collection whether or not the
	// client held the row.
	Key string `json:"key,omitempty" doc:"Primary key of the row, or empty when the whole table is invalidated"`

	// Op is what happened.
	Op Change `json:"op" enum:"create,update,delete" doc:"What happened to the row"`

	// Scope is the value of the column the model declared `scope`, when it
	// declared one: the tenant the changed row belonged to.
	//
	// It is **not on the wire**. It exists so that [EventsOptions.Filter] can
	// answer the one question a multi-tenant deployment has to answer about
	// this stream — is this event mine — without the endpoint hard-coding what
	// a tenant is. A subscriber gains nothing from being told its own tenant
	// id, and putting it on the wire would enlarge a contract ADR-0045 records
	// as the expensive half to change.
	//
	// It is empty when the model declares no scope, and empty on a delete,
	// which carries no row to read it from. A Filter comparing scopes has to
	// decide what an empty one means; the safe reading is that such an event
	// identifies nothing and may go to everyone, since it names neither a row
	// nor a tenant.
	Scope string `json:"-"`
}

Event is one invalidation: a row changed, and anything displaying it is now stale.

It carries no row data, which is ADR-0012's decision rather than an omission. A payload would have to be produced per subscriber, under that subscriber's context, or the resource's BeforeQuery scope would not apply to it — and a change feed that skips the scope hands one tenant's rows to another. Sending the address of the change instead keeps the read path the only thing that ever reads, so every rule the read path enforces still holds.

The generated TypeScript client emits a `keysByTable` map, which is the other half of this: a client receiving `{table: "posts", key: "42"}` looks up which of its cached queries to invalidate and refetches them through the ordinary GET endpoints.

type EventsOptions added in v0.6.0

type EventsOptions struct {
	// Source supplies the events. Required.
	Source Source

	// Path is where the stream is served. Defaults to "/events".
	Path string

	// Heartbeat is how often a comment line is written to an idle stream, to
	// keep an intermediary from reclaiming a connection it believes is dead.
	// Defaults to 25 seconds, which is under the 30 seconds that is the
	// shortest idle timeout in common proxy defaults. Negative disables it.
	Heartbeat time.Duration

	// Retry is the reconnection delay the endpoint suggests to the client,
	// sent once when the stream opens. Defaults to 3 seconds.
	Retry time.Duration

	// Filter, if set, decides whether one event reaches this subscriber. It
	// runs per event per subscriber, with the request's context, so it can
	// reach whatever the authentication middleware put there.
	//
	// Read the default — every subscriber receives every event — as the thing
	// to think about before mounting this on a multi-tenant deployment. The
	// events carry no row data, but a primary key is still a fact about what
	// exists, and nothing else on this path is scoped: an Event is published
	// by a write, not read through a query, so the BeforeQuery hook that
	// confines every other read of that table does not run here.
	//
	// One consequence to know about: a filtered event's id is never written,
	// so a subscriber that is filtered out of everything keeps an old
	// Last-Event-ID and will eventually be told to reset when it reconnects.
	// That costs it a refetch, which is the safe direction — the alternative
	// is advancing its position past events it was not shown, and then a
	// genuine gap would be indistinguishable from a filtered one.
	Filter func(ctx context.Context, e Event) bool

	// Summary, Description and Tag document the operation. Each has a default.
	Summary     string
	Description string
	Tag         string

	// Security is the OpenAPI security requirement the operation carries, in
	// the same shape and with the same meaning as Options.Security: it
	// documents, and middleware enforces.
	Security []map[string][]string
}

EventsOptions describes the change-feed endpoint.

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
	// MaxOffset bounds how deep ?page= and ?offset= may reach into the result
	// set. Offset paging is the one dimension of a request whose cost grows
	// with the number the client sent, so it has a ceiling like the others; a
	// request past it is refused with a message pointing at ?cursor=.
	MaxOffset 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

	// Computed lists the computed columns this resource selects. Each must be a
	// column the model computes, and it is checked at startup for the reason
	// Expandable is.
	//
	// Leaving it empty offers none, which is the right default for the same
	// reason it is right for Expandable: a computed column is a cost, and one
	// the schema happens to declare is not the same thing as one this resource
	// wants to serve. A model is shared — the same Project is read by a list
	// screen that wants four aggregates and by an existence check that wants
	// none — so projecting every declared column charged every reader for the
	// most expensive one, and a column carrying a Needs bind failed the
	// cheapest readers outright (#92).
	//
	// A column not listed is not reachable from this resource: not in the
	// response, not filterable, not sortable, not nameable in ?select. The
	// obligation follows the selection — a resource that selects a column
	// declaring Needs still refuses to mount without a hook to supply the bind,
	// and one that does not select it no longer has to care.
	Computed []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 Publisher added in v0.6.0

type Publisher interface {
	Publish(events ...Event)
}

Publisher is what a write announces itself to. Broker is one; a test double or an adapter onto an existing message bus is another.

type Reset added in v0.6.0

type Reset struct {
	Reason string `json:"reason" doc:"Why the stream could not resume"`
}

Reset tells a subscriber that its position in the stream could not be honoured and it should refetch everything it displays.

It is what makes a dropped event safe. Every other failure mode in this file — a slow client, a restart, a gap longer than the replay history — is converted into one of these rather than into a silently missing invalidation, because a client that never learns a row changed shows stale data forever, while a client told to refetch is merely doing extra work.

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 Source added in v0.6.0

type Source interface {
	// Subscribe returns the channel this subscriber's deliveries arrive on.
	//
	// since is the position the client last saw, from Last-Event-ID, or zero
	// for a fresh connection. A Source that can replay from that position
	// should; one that cannot must open the stream with a Delivery carrying a
	// Reset, so the gap is announced rather than skipped.
	//
	// The channel is closed when ctx is done, and may also be closed by the
	// Source to disconnect a subscriber it can no longer keep up with. A
	// closed channel ends the stream; the client reconnects on its own.
	Subscribe(ctx context.Context, since uint64) (<-chan Delivery, error)
}

Source is where an event stream gets its events.

This is the seam. Broker implements it in-process, which is what ships today and is honest about its limits (see its documentation). The outbox dispatcher ADR-0012 describes — durable, at-least-once, correct across replicas — implements the same two-method contract and replaces the Broker without the endpoint, the wire format or any client changing.

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