Skip to content

Mounting resources

The premise: the HTTP-to-SQL layer of a filter/sort/search page is mostly boilerplate, and it is boilerplate you should not have to write. What makes that safe rather than reckless is that the URL grammar compiles into the same predicate AST your Go code produces — so one compiler, one bind-parameter discipline, and one set of hooks cover both.

The batteries-included path is one call. rest.NewServer builds a huma API on net/http — no third-party router — and has huma serve the OpenAPI document and its docs page for you:

srv := rest.NewServer(rest.Config{Title: "Blog", Version: "1.0.0"})
if err := blog.Register(srv.API, db); err != nil { // generated from the schema
return err
}
http.ListenAndServe(":8080", srv.Handler) // wrap srv.Handler with your middleware

That is the default because importing sqlb should cost nothing: the engine and the REST adapter reach a consumer’s module graph as the standard library plus huma, and no router beyond net/http.

rest mounts on a huma.API, not a router it builds, so chi, gin, echo — and all of that router’s middleware — stay a first-class choice. Build the API with the matching huma adapter and hand it to the same generated Register:

router := chi.NewRouter()
router.Use(middleware.RequestID, middleware.Recoverer, yourAuth)
api := humachi.New(router, huma.DefaultConfig("Blog", "1.0.0"))
if err := blog.Register(api, db); err != nil {
return err
}
http.ListenAndServe(":8080", router)

rest.NewServer is a convenience over this seam, not a replacement: whatever you pass blog.Register — the server’s srv.API or one you built yourself — is a plain huma.API. The two examples show both paths: example/blog mounts on the NewServer default, and example/tasks brings a chi router for its middleware.

blog.Register is one rest.Resource call per exposed table. Written out, one of them looks like this:

rest.Must(rest.Resource[blog.Post, blog.PostCreate, blog.PostPatch](api, db, rest.Options{
Path: "/posts",
Ops: rest.CRUD | rest.OpList,
}))

T is the row type, C the create body and U the update body. A resource exposing neither create nor update passes rest.None[T] for both. Registration is the startup path, so failures are returned rather than panicked — a mistake should name the resource that caused it.

rest.CRUD | rest.OpList is the fully exposed collection. The other named shape is rest.ReadsOpRead | OpList, generated reads with the writes left hand-written:

rest.Must(rest.Resource[blog.Post, rest.None[blog.Post], rest.None[blog.Post]](api, db, rest.Options{
Path: "/posts",
Ops: rest.Reads,
}))

That is the mount an application adopting sqlb into an existing REST surface reaches for, and it is deliberate rather than unfinished: the app already has its writes, and the reasons they stay hand-written are domain reasons that do not expire — a create that writes bytes to object storage before the row, a row born in one domain verb and closed in another, a column whose transition is the publish that notifies the org, per-field authorization a hook can constrain but not express. Reaching for rest.CRUD and switching two thirds of it off describes the same mount as a shortfall; this one names it (issue #101).

The handlers are not generated: rest.Resource[T, C, U] is one generic function serving every resource. What is per-resource is the OpenAPI document, built from each column’s capabilities.

example/tasks/app/app.go is this assembled for real: authentication middleware, six generated resources mounted in one call, and six hand-written endpoints on the same router and in the same OpenAPI document. The thing to notice is what the generated half does not contain — no mention of tenants, tokens or roles anywhere in it, because the hooks cover those for every read the handlers issue.

Operation Endpoint Notes
OpList GET /posts Filtering, sorting, search, pagination, ?expand
OpRead GET /posts/{id} ?expand is its only query parameter
OpCreate POST /posts Body is C; returns the stored row
OpUpdate PATCH /posts/{id} Body is U; reports its own change set
OpDelete DELETE /posts/{id} A real DELETE

An operation the schema does not expose has no endpoint — not a 405. That is also true of the generated TypeScript client and CLI: the function and the subcommand do not exist.

OpDelete issuing a real DELETE is why a soft-deleting table usually leaves it out and serves the removal as an update instead; the pair is written out in Your first app.

A collection has one path, and a parent relationship is a filter. The tasks of a list are GET /tasks?list_id=eq.<id>, not GET /lists/{id}/tasks — so sorting, projection, paging and ?expand all work on it unchanged, and it is the same request a capped ?expand tells a caller to follow for the rest of the children. The one real cost is that a parent which does not exist yields an empty page rather than a 404 (ADR-0038).

Options.Security puts an OpenAPI security requirement on every operation of a resource:

rest.Options{Path: "/posts", Ops: rest.CRUD | rest.OpList,
Security: []map[string][]string{{"bearerAuth": {}}}}

It documents; it does not enforce — authentication is middleware on your router and runs whether or not this is set. Leaving it empty produces operations that are protected and do not say so, which is what every reader of the document then has to guess about. Declare the scheme itself once on the API:

api.OpenAPI().Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"bearerAuth": {Type: "http", Scheme: "bearer", BearerFormat: "JWT"},
}

The generated clients do not read this, and that is not an oversight: they are built from the schema rather than from the document, and they take the credential from the transport your project supplies.

Codegen emits PostCreate and PostPatch because two problems need types rather than reflection.

PostCreate omits read-only columns — the database or a BeforeCreate hook owns those — and makes defaulted columns optional, so leaving one out means the database supplies the value rather than a zero overwriting it. Its Row() method builds the row to insert; returning an error there is a 422, which is where cross-field validation belongs.

Both halves of “the database or a hook” are live: the handler clears every read-only field before inserting, so a hand-written Row() cannot set one, and a hook still can. That is what makes a tenant id expressible as a column no request may name — see example/tasks, where four models are stamped that way.

PostPatch has every field as a pointer and reports which ones the request actually carried. A typed struct cannot tell “absent” from “zero”, which is the whole difficulty of PATCH, so the body reports its change set explicitly. An empty change set is a 400 rather than a no-op update, because it almost always means the client sent the wrong shape. Immutable columns are absent entirely.

rest.Resource wraps every generated create, update and delete in one, which is what gives a hook a commit to be after and what lets a hook read its own writes through sqlb.TxFrom(ctx). Reads are left alone, since one SELECT is atomic already.

Options.DisableTransactions opts out, and does not disable AfterCommit — it makes every registration fail at request time. Loud rather than silent, which is the point, but it makes the option a decision about the resource’s hooks and not only about its latency. See Hooks.

A model whose schema declares Scoped, or that carries a SoftDelete column, does not mount until a hook confines it. Register returns an error naming every missing registration and the declaration that asked for it.

Serving it instead would answer 200 with another tenant’s rows, which is the quietest wrong answer in the system. See Capabilities.