gorbital

package module
v0.3.4 Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: Apache-2.0 Imports: 71 Imported by: 0

Documentation

Overview

Package gorbital composes gorbital's modules into an application (ADR-0081). An app's main.go is one call to Main, which loads the configuration from the environment (LoadConfig), builds the app (New) and serves it (App.Run), or migrates its database (Migrate):

func main() {
	gorbital.Main(
		gorbital.WithModules(modules.All()...),
		gorbital.WithMigrations(migrations.FS),
	)
}

A Module declares one feature of an app: its routes, error mappings, permissions, runtime settings and feature flags. Declare adds the declarations to the app's registries before their stores are built, and Mount registers the error mappings and routes on the app's API.

Routes are declared with the generic functions Get, Post, Put, Patch and Delete on a Router, so handlers keep their typed input and output, and with them request validation and the OpenAPI document. Every route requires an authenticated actor unless it has guard.Public() (ADR-0082):

func Module() gorbital.Module {
	return gorbital.Module{
		Name:   "books",
		Errors: []httpx.Mapping{{Err: ErrISBNTaken, Status: http.StatusConflict, Code: "isbn_taken"}},
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			h := &handler{db: d.DB, audit: d.Audit}
			books := r.Group("/v1/books", gorbital.Tags("Books"))
			gorbital.Post(books, "", h.createBook, gorbital.Status(http.StatusCreated))
			gorbital.Get(books, "/{id}", h.getBook)
		},
	}
}

gorbital's own modules are packages of this module, added the same way: gorbital.dev/gorbital/opshttp (the operations API under /ops/), gorbital.dev/gorbital/flagshttp (GET /v1/flags) and gorbital.dev/gorbital/mailevents (the email provider's webhook). They read what the app built for all modules through Module.Platform (ADR-0083).

Stability: experimental until v0.2.0 (ADR-0015, ADR-0081).

Index

Examples

Constants

View Source
const (
	// MailDevMail sends every email to orb dev's mail catcher, read in the
	// Dev Portal (DEV_MAIL_SMTP_ADDR). The default in development.
	MailDevMail = "devmail"
	// MailMailpit sends every email to a Mailpit inbox (MAILPIT_SMTP_ADDR).
	MailMailpit = "mailpit"
	// MailProvider sends real email through the provider the app passes
	// with [WithMailer]. Always used in production.
	MailProvider = "provider"
)

Email delivery modes, the values of MAIL_DELIVERY.

View Source
const (
	// StorageLocal keeps files under STORAGE_LOCAL_DIR; development only.
	StorageLocal = "local"
	// StorageS3, StorageSpaces, StorageR2 and StorageMinIO are
	// S3-compatible services, opened by the store the app passes with
	// [WithStorage].
	StorageS3     = "s3"
	StorageSpaces = "spaces"
	StorageR2     = "r2"
	StorageMinIO  = "minio"
)

Storage drivers, the values of STORAGE_DRIVER.

View Source
const (
	DefaultScopeName         = "organisation"
	DefaultScopePathParam    = route.OrgIDParam
	DefaultScopeNotFoundCode = "org_not_found"
)

Defaults for the fields of a Scope left empty. They are the vocabulary of v0.1 and v0.2 organisation apps, whose HTTP contract they keep.

View Source
const RetentionJob = "retention"

RetentionJob is the name of the built-in job that calls every Retention.Delete once a day. Job names are public API: /ops/jobs keeps their configuration and history under them.

Variables

View Source
var ErrNotImplemented = errors.New("gorbital: not implemented")

ErrNotImplemented is what a decision the app hasn't made yet returns. It exists so that "nobody has written this rule" is a refusal in its own right, distinguishable from "the rule ran and said no": a module that returns it is unfinished, not restrictive.

orb gen module --scope custom writes a policy.go whose CanRead, CanWrite and Filter return it (ADR-0091). The generated module maps it to 501 not_implemented, its shipped test fails while any of the three still returns it, and orb doctor names the module until they don't. An unwritten access rule therefore refuses every request and fails the build, rather than serving every row to everyone:

// CanRead reports whether the actor in ctx may read this order.
func (p Policy) CanRead(ctx context.Context, order domain.Order) error {
	return gorbital.ErrNotImplemented // decide: who may read one order?
}

Application code is free to return it from anything else it has not finished. The framework itself never returns it.

View Source
var ErrScopeNotFound = errors.New("gorbital: scope not found")

ErrScopeNotFound is what a ScopeAuthorizer returns for a scope the actor is not a member of: one that doesn't exist, is deleted, or exists and doesn't have them. The guard answers all three, and a malformed ID, with 404 and the scope's NotFoundCode, so scope IDs can't be probed.

orgs.ErrOrgNotFound is treated the same way, so an organisations authorizer needs no change.

View Source
var ErrUsage = errors.New("usage")

ErrUsage marks an error in how a command was called, such as a missing argument. Main exits with status 2 for it; wrap it in a Command's errors: fmt.Errorf("%w: grant-role <email> <role>", gorbital.ErrUsage).

Functions

func Declare

func Declare(d Declarations, modules ...Module) error

Declare adds the modules' permissions, runtime settings and feature flags to d's registries, in module order. Call it once, before building the settings and flags stores and before freezing the permission catalog; then grant each role its permissions with Grants.

Permissions with OrgRoles go to d.OrgPermissions, the others to d.Permissions; grant organisation roles theirs with OrgGrants.

It returns an error naming the module for an invalid or duplicate module name, a permission declared by two modules, a permission with both Roles and OrgRoles, a missing registry, or an invalid declaration (which the registries report by panicking).

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/auth"
	"gorbital.dev/modules/settings"
)

func main() {
	var pageSize *settings.Setting[int]
	books := gorbital.Module{
		Name:        "books",
		Permissions: []gorbital.Permission{{Name: "books.book.read", Description: "Read books", Roles: []string{"user"}}},
		Settings:    func(r *settings.Registry) { pageSize = settings.Int(r, "books.page_size", 20) },
	}

	catalog := auth.NewCatalog()
	reg := settings.NewRegistry()
	if err := gorbital.Declare(gorbital.Declarations{Permissions: catalog, Settings: reg}, books); err != nil {
		panic(err)
	}
	catalog.Role("user", "Every signed-in user", gorbital.Grants("user", books)...)

	fmt.Println(reg.Keys(), pageSize != nil)
	fmt.Println(catalog.Permissions("user"))
}
Output:
[books.page_size] true
[books.book.read]

func Delete

func Delete[I, O any](r *Router, path string, handler func(context.Context, *I) (*O, error), opts ...RouteOption)

Delete registers a DELETE operation. See Get.

Example
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type bookIDInput struct {
	ID string `path:"id"`
}

func removeBook(context.Context, *bookIDInput) (*struct{}, error) { return nil, nil }

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Delete(r, "/v1/books/{id}", removeBook, gorbital.Status(http.StatusNoContent))
	}, http.MethodDelete, "/v1/books/{id}")
}
Output:
DELETE /v1/books/{id} id=books-delete-v1-books-by-id summary="Delete v1 books by ID" tags=[] secured=true deprecated=false

func Get

func Get[I, O any](r *Router, path string, handler func(context.Context, *I) (*O, error), opts ...RouteOption)

Get registers a GET operation for path under r's prefix. The handler takes the request context and its typed input, and returns its typed output or an error; Huma validates the input and documents both (ADR-0082).

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.Summary("Get a book"))
	}, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get a book" tags=[] secured=true deprecated=false

func Grants

func Grants(role string, modules ...Module) []string

Grants returns the permissions the modules give to the platform role role, sorted and without duplicates, for declaring the role in the permission catalog.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	books := gorbital.Module{Name: "books", Permissions: []gorbital.Permission{
		{Name: "books.book.read", Roles: []string{"user", "viewer"}},
		{Name: "books.book.write", Roles: []string{"user"}},
	}}
	shelves := gorbital.Module{Name: "shelves", Permissions: []gorbital.Permission{
		{Name: "shelves.shelf.read", Roles: []string{"viewer"}},
	}}
	fmt.Println(gorbital.Grants("user", books, shelves))
	fmt.Println(gorbital.Grants("viewer", books, shelves))
}
Output:
[books.book.read books.book.write]
[books.book.read shelves.shelf.read]

func Main

func Main(opts ...Option)

Main runs the app as a command-line program, for main.go:

func main() {
	gorbital.Main(
		gorbital.WithModules(modules.All()...),
		gorbital.WithMigrations(migrations.FS),
	)
}

The first argument chooses the command:

serve (default)             load the configuration from the environment, build the app with New and Run it
migrate [--status [--json]] apply pending migrations (Migrate), or report them and change nothing
migrate-down                roll back the most recent migration; development only
openapi [--dir <dir>]       print the OpenAPI document, built without a database, or write it
                            with a Postman collection and llms.txt into dir, which
                            is created when it doesn't exist
version [--json]            print the build's version, commit and Go version

Main never returns: it exits with status 0 on success, 1 on a runtime error, and 2 for a usage or configuration error, such as an unknown command or an invalid environment variable. Errors go to standard error, prefixed with the app's name.

Example
package main

import (
	"embed"

	"gorbital.dev/gorbital"
)

// migrationFiles stands in for an app's db/migrations package.
var migrationFiles embed.FS

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

func main() {
	// cmd/api/main.go of an app: go run ./cmd/api serves it, go run ./cmd/api
	// migrate migrates its database.
	main := func() {
		gorbital.Main(
			gorbital.WithModules(modulesAll()...),
			gorbital.WithMigrations(migrationFiles),
		)
	}
	_ = main
}

func Migrate

func Migrate(ctx context.Context, cfg Config, w io.Writer, opts ...Option) error

Migrate applies every pending migration: the merged goose history of the library, the modules and the app's own (WithMigrations) with the goose version table v0.1 apps use, then River's job tables. It reports each applied version to w. A database migrated by a v0.1 app has every library migration already, under the same versions, so nothing is applied twice.

Apps run it from the migrate command (Main) before starting a new version; New never migrates (ADR-0017). It returns an error for a missing DATABASE_URL, conflicting migrations (naming both files), or a migration that fails, after reporting the ones applied before it.

Example
package main

import (
	"context"
	"embed"
	"os"

	"gorbital.dev/gorbital"
)

// migrationFiles stands in for an app's db/migrations package.
var migrationFiles embed.FS

func main() {
	migrate := func(ctx context.Context, cfg gorbital.Config) error {
		// Prints "applied migration <version>" for each migration applied.
		return gorbital.Migrate(ctx, cfg, os.Stdout, gorbital.WithMigrations(migrationFiles))
	}
	_ = migrate
}

func Mount

func Mount(api huma.API, mapper *httpx.Mapper, deps Deps, modules ...Module) error

Mount adds each module's error mappings to mapper and registers its routes on api, in module order. The app calls it once, after Declare and after building the stores that deps hold; to export the OpenAPI document without a database, pass zero Deps.

api must declare the bearer security scheme (openapi.WithBearerAuth) when any route requires authentication, and errors are written as problem+json only when the app has called openapi.InstallErrors with mapper.

Mount returns the first error, naming the module: an invalid or duplicate module name, errors to map without a mapper, a mapping the mapper refuses, an invalid path, two routes with the same method and path or the same operation ID, or a route Huma can't register (such as an unsupported input type). Routes registered before the error stay registered, so an app treats any error as fatal.

Example
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/gorbital/guard"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// call sends a request, as the signed-in user usr_1 when signedIn, and returns
// the status and the problem code, if any.
func call(h http.Handler, method, target string, signedIn bool) string {
	req := httptest.NewRequest(method, target, nil)
	if signedIn {
		req = req.WithContext(actor.With(req.Context(), actor.Actor{Kind: actor.KindUser, ID: "usr_1"}))
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	var p struct {
		Code string `json:"code"`
	}
	if json.Unmarshal(rec.Body.Bytes(), &p) == nil && p.Code != "" {
		return fmt.Sprintf("%d %s", rec.Code, p.Code)
	}
	return fmt.Sprint(rec.Code)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	mux, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name: "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			gorbital.Get(r, "/v1/books/{id}", findBook)                   // signed-in callers only
			gorbital.Get(r, "/v1/catalog/{id}", findBook, guard.Public()) // anyone
		},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(call(mux, http.MethodGet, "/v1/books/bok_1", false))
	fmt.Println(call(mux, http.MethodGet, "/v1/books/bok_1", true))
	fmt.Println(call(mux, http.MethodGet, "/v1/catalog/bok_1", false))
}
Output:
401 unauthenticated
200
200
Example (DuplicateRoute)
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	_, api, mapper := newAPI()
	route := func(name string) gorbital.Module {
		return gorbital.Module{Name: name, Routes: func(r *gorbital.Router, d gorbital.Deps) {
			gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.OperationID(name+"-get-book"))
		}}
	}
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, route("books"), route("library"))
	fmt.Println(err)
}
Output:
gorbital: GET /v1/books/{id} is registered by modules "books" and "library"

func OrgGrants

func OrgGrants(role string, modules ...Module) []string

OrgGrants returns the permissions the modules give to the organisation role role (Permission.OrgRoles), sorted and without duplicates, for declaring the role in the organisation catalog.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/orgs"
)

func main() {
	invoices := gorbital.Module{
		Name: "invoices",
		Permissions: []gorbital.Permission{
			{Name: "invoices.invoice.read", Description: "See invoices", OrgRoles: []string{orgs.RoleOwner, orgs.RoleAdmin, orgs.RoleMember}},
			{Name: "invoices.invoice.void", Description: "Void invoices", OrgRoles: []string{orgs.RoleOwner}},
		},
	}
	fmt.Println(gorbital.OrgGrants(orgs.RoleOwner, invoices))
	fmt.Println(gorbital.OrgGrants(orgs.RoleMember, invoices))
	fmt.Println(gorbital.Grants(orgs.RoleOwner, invoices)) // platform roles hold none of them
}
Output:
[invoices.invoice.read invoices.invoice.void]
[invoices.invoice.read]
[]

func Patch

func Patch[I, O any](r *Router, path string, handler func(context.Context, *I) (*O, error), opts ...RouteOption)

Patch registers a PATCH operation. See Get.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Patch(r, "/v1/books/{id}", findBook, gorbital.Summary("Update a book"))
	}, http.MethodPatch, "/v1/books/{id}")
}
Output:
PATCH /v1/books/{id} id=books-patch-v1-books-by-id summary="Update a book" tags=[] secured=true deprecated=false

func Post

func Post[I, O any](r *Router, path string, handler func(context.Context, *I) (*O, error), opts ...RouteOption)

Post registers a POST operation. See Get.

Example
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookBody struct{ Body Book }

type newBookInput struct {
	Body struct {
		Title string `json:"title" minLength:"1"`
	}
}

func addBook(_ context.Context, in *newBookInput) (*bookBody, error) {
	return &bookBody{Body: Book{ID: "bok_2", Title: in.Body.Title}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Post(r, "/v1/books", addBook, gorbital.Status(http.StatusCreated))
	}, http.MethodPost, "/v1/books")
}
Output:
POST /v1/books id=books-post-v1-books summary="Post v1 books" tags=[] secured=true deprecated=false

func Put

func Put[I, O any](r *Router, path string, handler func(context.Context, *I) (*O, error), opts ...RouteOption)

Put registers a PUT operation. See Get.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Put(r, "/v1/books/{id}/title", findBook, gorbital.Summary("Replace a book's title"))
	}, http.MethodPut, "/v1/books/{id}/title")
}
Output:
PUT /v1/books/{id}/title id=books-put-v1-books-by-id-title summary="Replace a book's title" tags=[] secured=true deprecated=false

func ScopeGrants added in v0.3.0

func ScopeGrants(role string, modules ...Module) []string

ScopeGrants returns the permissions the modules give to the scope role role (Permission.ScopeRoles), sorted and without duplicates, for declaring the role in the scope catalog. It is OrgGrants under the name the framework now uses for tenancy (ADR-0088).

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	orders := gorbital.Module{
		Name: "orders",
		Permissions: []gorbital.Permission{
			{Name: "orders.order.read", Description: "See orders", ScopeRoles: []string{"owner", "manager", "courier"}},
			{Name: "orders.order.refund", Description: "Refund an order", ScopeRoles: []string{"owner"}},
		},
	}
	fmt.Println(gorbital.ScopeGrants("owner", orders))
	fmt.Println(gorbital.ScopeGrants("courier", orders))
	fmt.Println(gorbital.Grants("owner", orders)) // platform roles hold none of them
}
Output:
[orders.order.read orders.order.refund]
[orders.order.read]
[]

Types

type App

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

An App is a built application: its HTTP handler, background workers and the resources they hold. New builds it; App.Run serves it until a shutdown signal; App.Close releases it without running.

Example
package main

import (
	"context"

	"gorbital.dev/gorbital"
)

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

func main() {
	serve := func(ctx context.Context, cfg gorbital.Config) error {
		app, err := gorbital.New(ctx, cfg, gorbital.WithModules(modulesAll()...))
		if err != nil {
			return err
		}
		return app.Run(ctx) // until SIGINT or SIGTERM
	}
	_ = serve
}

func New

func New(ctx context.Context, cfg Config, opts ...Option) (*App, error)

New builds the app from cfg in dependency order (ADR-0081): telemetry, the database pool, the audit log, the settings and flags registries with every module's declarations, their stores, email delivery, rate limits, idempotency keys, file storage, request metrics, the job definitions and client, the mailer modules send through, release tracking, the dev console, then the modules' routes and the middleware stack. Every step is a public constructor of a library module, which an app can also call itself.

New connects to PostgreSQL and loads the stored settings, flags and job overrides, but never migrates (ADR-0017): run Migrate first. Pending migrations are logged as a warning.

It returns an error, after closing whatever it had opened, for a missing DATABASE_URL, an unreachable database, conflicting migrations, a module declaration that fails (a duplicate module, route, operation ID, permission, setting, flag or job, naming both modules), an S3-compatible STORAGE_DRIVER without WithStorage, or MAIL_DELIVERY=provider without WithMailer.

Example
package main

import (
	"context"
	"embed"
	"log"
	"os"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// migrationFiles stands in for an app's db/migrations package.
var migrationFiles embed.FS

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

func main() {
	// What Main does for the serve command, for an app that builds its own
	// program or embeds the handler in another server.
	ctx := context.Background()
	cfg, err := gorbital.LoadConfig(config.OS)
	if err != nil {
		log.Fatal(err)
	}
	opts := []gorbital.Option{gorbital.WithModules(modulesAll()...), gorbital.WithMigrations(migrationFiles)}
	if err := gorbital.Migrate(ctx, cfg, os.Stdout, opts...); err != nil {
		log.Fatal(err)
	}
	app, err := gorbital.New(ctx, cfg, opts...)
	if err != nil {
		log.Fatal(err)
	}
	if err := app.Run(ctx); err != nil {
		log.Fatal(err)
	}
}

func (*App) Close

func (a *App) Close(ctx context.Context) error

Close releases the app's resources without running it, in the reverse order New opened them. Call it when Run is never called, such as in tests.

Example
package main

import (
	"context"
	"errors"

	"gorbital.dev/gorbital"
)

func main() {
	check := func(ctx context.Context, cfg gorbital.Config) (err error) {
		app, err := gorbital.New(ctx, cfg)
		if err != nil {
			return err
		}
		defer func() { err = errors.Join(err, app.Close(ctx)) }()
		// Use the app without running it.
		return nil
	}
	_ = check
}

func (*App) Deps

func (a *App) Deps() Deps

Deps returns the dependencies the app passes to its modules, for commands, seed data and tests that use the same stores.

Example
package main

import (
	"context"

	"gorbital.dev/gorbital"
)

func main() {
	seed := func(ctx context.Context, app *gorbital.App) error {
		_, err := app.Deps().DB.Exec(ctx, `INSERT INTO books (id, title) VALUES ('bok_1', 'Dune') ON CONFLICT DO NOTHING`)
		return err
	}
	_ = seed
}

func (*App) Handler

func (a *App) Handler() http.Handler

Handler returns the app's HTTP handler: every route behind the middleware stack, as Run serves it. Use it in tests, or to serve the app from another server; background workers run only with Run.

Example
package main

import (
	"net/http"

	"gorbital.dev/gorbital"
)

func main() {
	mount := func(app *gorbital.App) http.Handler {
		// The app under /api/ of another server.
		mux := http.NewServeMux()
		mux.Handle("/api/", http.StripPrefix("/api", app.Handler()))
		return mux
	}
	_ = mount
}

func (*App) Run

func (a *App) Run(ctx context.Context) error

Run serves HTTP and runs the background workers until ctx is done or a shutdown signal (SIGINT, SIGTERM) arrives, then shuts down in the order of ADR-0017: readiness answers 503, the drain delay passes (5 seconds in production, none in development), the server stops taking requests and the workers stop, then resources close in the reverse order New opened them, telemetry last. It returns nil after a clean shutdown, or every failure joined.

The workers are the settings, flags and job definition listeners, the job client, the release heartbeat and the request collector, plus the metrics listener when METRICS_ADDR is set. Run releases the app's resources when it returns, so a Close after it does nothing.

Example
package main

import (
	"context"

	"gorbital.dev/gorbital"
)

func main() {
	run := func(ctx context.Context, app *gorbital.App) error {
		// Run stops when ctx is done, as well as on a signal.
		ctx, cancel := context.WithCancel(ctx)
		defer cancel()
		return app.Run(ctx)
	}
	_ = run
}

type AuthConfig

type AuthConfig struct {
	// EncryptionKeys encrypt authenticator app secrets
	// (AUTH_ENCRYPTION_KEYS: comma-separated id:base64 entries).
	EncryptionKeys config.Secret
	// PublicURL is the API's public base URL, where sign-in providers
	// return (APP_PUBLIC_URL; http://localhost:8080 in development).
	PublicURL string
	// DefaultReturnTo is where a web sign-in started without return_to
	// ends (AUTH_DEFAULT_RETURN_TO; the API docs in development).
	DefaultReturnTo string

	// WebAuthnRPID is the passkey relying party (WEBAUTHN_RP_ID; localhost
	// in development when neither it nor the origins are set).
	WebAuthnRPID string
	// WebAuthnOrigins are the browser origins using passkeys
	// (WEBAUTHN_ORIGINS).
	WebAuthnOrigins []string
	// WebAuthnAppleAppIDs and WebAuthnAndroidApps are the raw values of
	// WEBAUTHN_APPLE_APP_IDS and WEBAUTHN_ANDROID_APPS.
	WebAuthnAppleAppIDs string
	WebAuthnAndroidApps string

	GoogleClientID        string        // GOOGLE_CLIENT_ID: the web client
	GoogleClientSecret    config.Secret // GOOGLE_CLIENT_SECRET
	GoogleIOSClientID     string        // GOOGLE_IOS_CLIENT_ID
	GoogleAndroidClientID string        // GOOGLE_ANDROID_CLIENT_ID

	AppleTeamID     string        // APPLE_TEAM_ID
	AppleServicesID string        // APPLE_SERVICES_ID: web sign-in
	AppleKeyID      string        // APPLE_KEY_ID
	ApplePrivateKey config.Secret // APPLE_PRIVATE_KEY, or the file APPLE_PRIVATE_KEY_FILE names
	AppleBundleIDs  []string      // APPLE_BUNDLE_IDS: sign-in in iOS apps

	GitHubClientID     string        // GITHUB_CLIENT_ID
	GitHubClientSecret config.Secret // GITHUB_CLIENT_SECRET
}

AuthConfig holds the sign-in variables of a v0.1 app: the encryption keys for second-factor secrets, passkeys, and Google, Apple and GitHub sign-in (ADR-0043 to ADR-0046, ADR-0059). gorbital reads and checks them; the authenticator passed with WithAuth uses them.

Checks that need a sign-in provider's own package are the authenticator's, so apps without sign-in don't compile those packages: parsing the Apple private key, WEBAUTHN_APPLE_APP_IDS and WEBAUTHN_ANDROID_APPS, and matching WEBAUTHN_ORIGINS to WEBAUTHN_RP_ID (ADR-0083).

Example
package main

import (
	"fmt"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// developmentEnv is a configuration source for the examples.
func developmentEnv(vars map[string]string) config.Source {
	return config.Source{Getenv: func(k string) string { return vars[k] }}
}

func main() {
	cfg, err := gorbital.LoadConfig(developmentEnv(map[string]string{
		"APP_ENV": "development", "GITHUB_CLIENT_ID": "Iv1.abc", "GITHUB_CLIENT_SECRET": "secret",
	}))
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Auth.PublicURL, cfg.Auth.DefaultReturnTo, cfg.Auth.WebAuthnRPID)
}
Output:
http://localhost:8080 http://localhost:8080/docs localhost

type AuthSetup

type AuthSetup struct {
	// Name is the app's name ([WithName]).
	Name string
	// Config is the loaded configuration.
	Config Config
	// Deps are the app's dependencies, with an untagged logger; zero from
	// Main.
	Deps Deps
	// Permissions is the app's permission catalog: every module's
	// permissions, and a role for every role name they use, with the
	// permissions the modules grant it. It isn't frozen yet, so the
	// authenticator can declare the roles it relies on and require a second
	// factor for roles; the authenticator freezes it.
	Permissions *auth.Catalog
	// DevConsole reports whether the app serves the development console
	// (APP_ENV=development with DEV_CONSOLE_TOKEN): features that must never
	// run in production, such as impersonation, check it.
	DevConsole bool
	// Handle serves a handler outside the OpenAPI document on the app's mux,
	// behind the middleware stack, such as the /.well-known files passkeys
	// need. The pattern is an http.ServeMux pattern with a method, such as
	// "GET /.well-known/assetlinks.json". The dev console lists it. Call it
	// during Setup: New mounts the handlers once Setup returns, and fails
	// for a pattern that conflicts with another route. It does nothing from
	// Main.
	Handle func(pattern string, handler http.Handler)
	// MailPreviews adds emails the dev console previews and sends with
	// sample data (ADR-0074). Call it during Setup. It does nothing from
	// Main.
	MailPreviews func(previews ...auth.EmailPreview)
	// DevEndpoints serves handler under prefix, a path under /_dev/ ending
	// in a slash such as "/_dev/auth/test/", as part of the development
	// console: behind its Host, loopback, forwarding-header and token checks
	// (ADR-0065, ADR-0086), for development tools such as sign-in's tests
	// (ADR-0087). Call it during Setup. It does nothing when DevConsole is
	// false and from Main; New fails for a prefix the console refuses.
	DevEndpoints func(prefix string, handler http.Handler)
}

AuthSetup is what the app hands its authenticator before using it: the configuration, the dependencies and the permission catalog. An Authenticator with a method

Setup(ctx context.Context, s gorbital.AuthSetup) error

receives it, explicitly and once per app:

  • from New, after the stores exist and before the modules' jobs, routes and the middleware stack are built, with the app's Deps; an error fails New;
  • from Main, before a command the authenticator contributes runs, with zero Deps: the command opens what it needs from Config.

An authenticator that also has a method CheckConfig(cfg Config) error has it called first, by New before connecting to anything and by Main before such a command; its error is a configuration error (exit status 2), as for LoadConfig. gorbital.dev/gorbital/authhttp checks there what needs sign-in's own packages, such as the Apple private key.

Example
package main

import (
	"context"
	"errors"
	"log/slog"
	"net/http"

	"gorbital.dev/gorbital"
)

// tokenAuth is an authenticator that reads its configuration and database
// from the app, as gorbital.dev/gorbital/authhttp does.
type tokenAuth struct {
	app  string
	deps gorbital.Deps
}

func (a *tokenAuth) Middleware(*slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Look the bearer token up in a.deps.DB and set the actor.
			next.ServeHTTP(w, r)
		})
	}
}

// CheckConfig runs before anything connects; an error exits with status 2.
func (a *tokenAuth) CheckConfig(cfg gorbital.Config) error {
	if cfg.Production() && cfg.Auth.EncryptionKeys.IsZero() {
		return errors.New("AUTH_ENCRYPTION_KEYS is required in production")
	}
	return nil
}

// Setup receives the app's dependencies once the stores exist.
func (a *tokenAuth) Setup(_ context.Context, s gorbital.AuthSetup) error {
	a.app, a.deps = s.Name, s.Deps
	if !s.Permissions.HasRole("user") {
		s.Permissions.Role("user", "Every signed-in user")
	}
	s.Permissions.Freeze()
	s.Handle("GET /.well-known/token-issuer", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(a.app))
	}))
	return nil
}

func main() {
	main := func() {
		gorbital.Main(gorbital.WithAuth(&tokenAuth{}), gorbital.WithModules(modulesAll()...))
	}
	_ = main
}

type Authenticator

type Authenticator interface {
	Middleware(logger *slog.Logger) func(http.Handler) http.Handler
}

An Authenticator resolves who makes each request. Its middleware runs at the Auth step of the middleware stack (Stack) and sets the actor (actor.With, or auth.WithPrincipal) for authenticated requests; requests it can't authenticate pass through without one, and every route that isn't guard.Public() then answers 401.

A value that also has a method Module() Module contributes that module too: its routes, permissions, settings, jobs and migrations. One with a method Setup(ctx, AuthSetup) error receives the app's configuration, dependencies and permission catalog before it serves, and one with a method CheckConfig(Config) error checks the configuration first (AuthSetup), and one with a method SignInMethods(Config) []SignInMethod reports its sign-in methods to the operations API (Platform.SignInMethods). gorbital.dev/gorbital/authhttp has all of them.

Example
package main

import (
	"log/slog"
	"net/http"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
)

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

// headerAuth is an Authenticator trusting a gateway's header, for the
// examples.
type headerAuth struct{}

func (headerAuth) Middleware(*slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if id := r.Header.Get("X-Authenticated-User"); id != "" {
				r = r.WithContext(actor.With(r.Context(), actor.Actor{Kind: actor.KindUser, ID: id}))
			}
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	var a gorbital.Authenticator = headerAuth{}
	gorbital.Main(gorbital.WithAuth(a), gorbital.WithModules(modulesAll()...))
}

type Command

type Command struct {
	// Name is what follows the program name, such as "grant-role". It
	// can't be a built-in command's name.
	Name string
	// Usage is one line: the arguments, then what the command does, such as
	// "grant-role <email> <role>   give an account a platform role".
	Usage string
	// Run runs the command with the loaded configuration and the arguments
	// after the name. Return an error wrapping [ErrUsage] for bad
	// arguments.
	Run func(ctx context.Context, cfg Config, args []string, stdout io.Writer) error
}

A Command is a subcommand of Main beside the built-in ones. A value passed to WithAuth that has a method Commands() []Command contributes its commands, as the built-in sign-in does for its role commands.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"os"

	"gorbital.dev/gorbital"
)

func main() {
	// A command an authenticator contributes from its Commands method.
	grantRole := gorbital.Command{
		Name:  "grant-role",
		Usage: "grant-role <email> <role>      give an account a platform role",
		Run: func(ctx context.Context, cfg gorbital.Config, args []string, stdout io.Writer) error {
			if len(args) != 2 {
				return fmt.Errorf("%w: grant-role <email> <role>", gorbital.ErrUsage)
			}
			fmt.Fprintf(stdout, "%s is now %s\n", args[0], args[1])
			return nil
		},
	}
	err := grantRole.Run(context.Background(), gorbital.Config{}, []string{"ada@example.com"}, os.Stdout)
	fmt.Println(errors.Is(err, gorbital.ErrUsage))
}
Output:
true

type Config

type Config struct {
	// Env is development or production (APP_ENV, required).
	Env string
	// Addr is where the API listens (APP_ADDR, default 127.0.0.1:8080).
	Addr     string
	LogLevel slog.Level // APP_LOG_LEVEL: debug, info, warn or error
	// LogFormat is json or text (APP_LOG_FORMAT); empty means JSON in
	// production and text elsewhere.
	LogFormat string
	// LogArchiveDir is where the hourly log archive spools the current hour
	// (LOG_ARCHIVE_DIR, default .orb/logs; ADR-0079).
	LogArchiveDir string
	// DocsEnabled serves /docs and the OpenAPI document (APP_DOCS_ENABLED;
	// default on in development, off in production).
	DocsEnabled bool
	// CORSOrigins are the browser origins allowed to call the API
	// (APP_CORS_ORIGINS, comma-separated; https in production).
	CORSOrigins []string
	// TrustedProxies are the load balancers whose X-Forwarded-For names the
	// client (APP_TRUSTED_PROXIES, ADR-0052).
	TrustedProxies []netip.Prefix
	// TrustedCallers are the gateways whose X-Request-ID and trace context
	// the app accepts (APP_TRUSTED_CALLERS).
	TrustedCallers []netip.Prefix
	// OpsAllowedIPs are the client addresses allowed to call the operations
	// API under /ops/ (OPS_ALLOWED_IPS, comma-separated ranges or
	// addresses; ADR-0085). Empty allows every address. The address is the
	// client's after APP_TRUSTED_PROXIES.
	OpsAllowedIPs []netip.Prefix
	// MaxBodyBytes limits request bodies (APP_MAX_BODY_BYTES, default 1 MiB).
	MaxBodyBytes int64
	// RequestTimeout is how long a handler may take before the Timeout
	// step answers 503 request_timeout (APP_REQUEST_TIMEOUT, default 30s;
	// 0 turns it off). It must be shorter than the server's write timeout,
	// httpx.DefaultWriteTimeout, so the 503 can still be sent.
	RequestTimeout time.Duration
	// OTLPEndpoint exports traces and metrics when set
	// (OTEL_EXPORTER_OTLP_ENDPOINT).
	OTLPEndpoint string
	// MetricsAddr is the separate listener serving Prometheus metrics
	// (METRICS_ADDR; empty turns it off; never APP_ADDR's port).
	MetricsAddr string

	// DatabaseURL is the PostgreSQL connection URL (DATABASE_URL), required
	// by [New] and migrations but not by exporting the OpenAPI document.
	DatabaseURL config.Secret
	// DBMaxConns sizes the connection pool (APP_DB_MAX_CONNS, 1–1000,
	// default 10).
	DBMaxConns int32
	// JobWorkers is how many jobs run at once (APP_JOB_WORKERS, 1–10000,
	// default 10).
	JobWorkers int

	// MailDelivery is MailDevMail, MailMailpit or MailProvider
	// (MAIL_DELIVERY; production allows only MailProvider).
	MailDelivery string
	// MailpitAddr is Mailpit's SMTP address (MAILPIT_SMTP_ADDR, default
	// 127.0.0.1:1025).
	MailpitAddr string
	// DevMailAddr is orb dev's mail catcher (DEV_MAIL_SMTP_ADDR, default
	// 127.0.0.1:1025; ADR-0074).
	DevMailAddr string
	// Mail holds the email provider's secrets.
	Mail MailConfig
	// Storage is the file storage driver and its settings (ADR-0075).
	Storage StorageConfig
	// Auth holds the sign-in variables, for the authenticator.
	Auth AuthConfig
	// DevConsole turns on the development console under /_dev/.
	DevConsole DevConsoleConfig

	// EnvKeys are the environment variables LoadConfig read, secrets as set
	// or unset only, listed by the dev console's GET /_dev/config.
	EnvKeys []devconsole.EnvKey
}

Config is every boot setting of an app: secrets and infrastructure, read from environment variables by LoadConfig. The names, defaults and checks are those of a v0.1 app's internal/app/config.go, so a v0.1 deployment's environment works unchanged. Values operators change at runtime are runtime settings, not configuration (ADR-0031).

Example
package main

import (
	"fmt"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// developmentEnv is a configuration source for the examples.
func developmentEnv(vars map[string]string) config.Source {
	return config.Source{Getenv: func(k string) string { return vars[k] }}
}

func main() {
	cfg, err := gorbital.LoadConfig(developmentEnv(map[string]string{"APP_ENV": "development", "APP_JOB_WORKERS": "4"}))
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Addr, cfg.JobWorkers, cfg.MailDelivery, cfg.DocsEnabled)
}
Output:
127.0.0.1:8080 4 devmail true

func LoadConfig

func LoadConfig(src config.Source) (Config, error)

LoadConfig reads the configuration from src, such as config.OS. It reports every invalid value at once, joined, each naming its variable, so a misconfigured deployment fails on its first start. It never connects to anything.

Production refuses: a missing or unknown APP_ENV; http CORS origins, passkey origins, public URL or default return address; MAIL_DELIVERY other than provider; STORAGE_DRIVER=local; and DEV_CONSOLE_TOKEN.

Example
package main

import (
	"fmt"
	"strings"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// developmentEnv is a configuration source for the examples.
func developmentEnv(vars map[string]string) config.Source {
	return config.Source{Getenv: func(k string) string { return vars[k] }}
}

func main() {
	cfg, err := gorbital.LoadConfig(developmentEnv(map[string]string{
		"APP_ENV":          "production",
		"APP_ADDR":         "api",
		"APP_CORS_ORIGINS": "http://app.example.com",
		"STORAGE_DRIVER":   "local",
	}))
	fmt.Println(cfg.Env == "") // the zero Config on error
	for line := range strings.Lines(err.Error()) {
		fmt.Print(line)
	}
}
Output:
true
invalid configuration:
APP_ADDR "api" is not host:port: address api: missing port in address
APP_CORS_ORIGINS: "http://app.example.com" must use https in production
STORAGE_DRIVER=local is for development; production needs s3, spaces, r2 or minio with STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY

func (Config) Production

func (c Config) Production() bool

Production reports whether the app runs in production mode.

Example
package main

import (
	"fmt"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// developmentEnv is a configuration source for the examples.
func developmentEnv(vars map[string]string) config.Source {
	return config.Source{Getenv: func(k string) string { return vars[k] }}
}

func main() {
	cfg, _ := gorbital.LoadConfig(developmentEnv(map[string]string{"APP_ENV": "development"}))
	fmt.Println(cfg.Production())
}
Output:
false

type Declarations

type Declarations struct {
	// Permissions receives every module's platform permissions; nil skips
	// them.
	Permissions PermissionDeclarer
	// OrgPermissions receives every module's organisation permissions, those
	// with [Permission.OrgRoles]; nil skips them.
	OrgPermissions PermissionDeclarer
	// Settings and Flags are required when a module declares settings or
	// flags.
	Settings *settings.Registry
	Flags    *flags.Registry
}

Declarations are the registries Declare adds modules' declarations to.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/flags"
)

func main() {
	books := gorbital.Module{
		Name:  "books",
		Flags: func(r *flags.Registry) { flags.Bool(r, "books.covers", flags.Describe("Show cover images")) },
	}
	err := gorbital.Declare(gorbital.Declarations{}, books)
	fmt.Println(err)
}
Output:
gorbital: module "books" declares flags, but Declarations.Flags is nil

type Deps

type Deps struct {
	DB       *pgxpool.Pool
	Audit    audit.Recorder
	Mailer   mail.Sender
	Jobs     *jobs.Client
	Settings *settings.Store
	Flags    *flags.Store
	Storage  storage.Store
	// RateLimits shares guard.RateLimit budgets across instances; without
	// it, each instance counts on its own.
	RateLimits *ratelimitpg.Store
	// Logger is tagged with the module's name by [Mount].
	Logger *slog.Logger
}

Deps are the app's shared dependencies, passed to each module's Routes.

Every field may be nil: all of them are while the OpenAPI document is exported without a database, and Storage is nil unless the app configures file storage. A module registers the same routes either way and uses its dependencies only when handling requests.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	books := gorbital.Module{
		Name: "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			// d.DB, d.Mailer and the others are nil while the OpenAPI
			// document is exported; use them only in handlers.
			d.Logger.Info("registering routes", "database", d.DB != nil)
			gorbital.Get(r, "/v1/books/{id}", findBook)
		},
	}
	_, api, mapper := newAPI()
	fmt.Println(gorbital.Mount(api, mapper, gorbital.Deps{}, books))
}
Output:
<nil>

type DevConsoleConfig

type DevConsoleConfig struct {
	// Token turns the console on in development (DEV_CONSOLE_TOKEN, set by
	// orb dev; refused in production).
	Token config.Secret
	// MailpitWebPort is the port of Mailpit's web interface on
	// MAILPIT_SMTP_ADDR's host (MAILPIT_WEB_PORT, default 8025).
	MailpitWebPort string
}

DevConsoleConfig is the development console's configuration (ADR-0065).

Example
package main

import (
	"fmt"
	"strings"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// developmentEnv is a configuration source for the examples.
func developmentEnv(vars map[string]string) config.Source {
	return config.Source{Getenv: func(k string) string { return vars[k] }}
}

func main() {
	_, err := gorbital.LoadConfig(developmentEnv(map[string]string{"APP_ENV": "production", "DEV_CONSOLE_TOKEN": strings.Repeat("x", 40)}))
	fmt.Println(strings.Contains(err.Error(), "DEV_CONSOLE_TOKEN is for local development only"))
}
Output:
true

type MailConfig

type MailConfig struct {
	// ResendAPIKey is RESEND_API_KEY.
	ResendAPIKey config.Secret
	// ResendWebhookSecret verifies Resend's bounce and complaint webhooks
	// (RESEND_WEBHOOK_SECRET, ADR-0062).
	ResendWebhookSecret config.Secret
}

MailConfig holds the email provider's secrets. The provider itself is the app's choice (WithMailer); these are the variables a v0.1 app's Resend provider reads, kept so a provider constructor can use them.

Example
package main

import (
	"context"
	"errors"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
	"gorbital.dev/mail"
)

func main() {
	// An email provider built from its secrets, for MAIL_DELIVERY=provider.
	provider := gorbital.WithMailerFunc(func(cfg gorbital.Config) (mail.Sender, error) {
		if cfg.Mail.ResendAPIKey.IsZero() {
			return nil, errors.New("RESEND_API_KEY is required to send email with Resend")
		}
		return newResendSender(cfg.Mail.ResendAPIKey), nil
	})
	_ = provider
}

// newResendSender stands in for resend.New.
func newResendSender(config.Secret) mail.Sender {
	return mail.SenderFunc(func(context.Context, mail.Message) error { return nil })
}

type Migration

type Migration struct {
	// Version orders the migration in the app's history, such as
	// 20260914000001. Released versions never change.
	Version int64
	// Name describes the migration in lowercase snake_case, such as
	// "settings"; the merged file is named <Version>_<Name>.sql.
	Name string
	// FS holds the file, usually the module's embedded migrations.
	FS fs.FS
	// File is the file's path in FS, such as "00001_settings.sql".
	File string
}

A Migration is one goose migration file a module contributes to the app's single migration history (ADR-0083). Library modules number their files locally, such as 00001_settings.sql; Version places the file in the app's history, and is the version a v0.1 app holds the same file under, so an upgraded database sees it as applied.

Example
package main

import (
	"embed"
	"fmt"

	"gorbital.dev/gorbital"
)

// migrationFiles stands in for an app's db/migrations package.
var migrationFiles embed.FS

func main() {
	// A built-in module serving its embedded migration under the version it
	// has in apps' histories.
	reviews := gorbital.Module{
		Name: "reviews",
		Migrations: []gorbital.Migration{
			{Version: 20270301000001, Name: "reviews", FS: migrationFiles, File: "00001_reviews.sql"},
		},
	}
	fmt.Println(reviews.Migrations[0].Version, reviews.Migrations[0].Name)
}
Output:
20270301000001 reviews

type Module

type Module struct {
	// Name identifies the module in operation IDs, errors and logs. It is
	// lowercase snake_case, such as "books", and unique in an app.
	Name string

	// Routes registers the module's operations. [Mount] calls it once.
	Routes func(r *Router, d Deps)

	// Errors map the module's errors to problem responses. Error codes are
	// public API: add new ones, never change existing ones (ADR-0015).
	Errors []httpx.Mapping

	// Permissions are the permissions the module checks and the roles that
	// hold them. Permission names are public API.
	Permissions []Permission

	// Settings declares the module's runtime settings. [Declare] calls it
	// once, before the settings store is built.
	Settings func(r *settings.Registry)

	// Flags declares the module's feature flags. [Declare] calls it once,
	// before the flags store is built.
	Flags func(r *flags.Registry)

	// Middleware runs on every route of the module, before group and route
	// middleware (see [Use]).
	Middleware []func(http.Handler) http.Handler

	// Jobs defines the module's background jobs with jobs.Define. [New]
	// calls it once, before the job client exists, because the client is
	// built from the definitions. So in the Deps it receives, Jobs is nil,
	// and Mailer queues through the job client [New] builds next: a worker
	// keeps d and uses it when a job runs, never inside Jobs itself. A
	// worker that enqueues other jobs gets the client from its context with
	// river.ClientFromContext.
	Jobs func(defs *jobs.Definitions, d Deps)

	// Migrations are the module's migrations, merged by version with the
	// library's and the app's (ADR-0083). App modules keep theirs in the
	// app's db/migrations ([WithMigrations]) so tables of different modules
	// can reference each other; built-in modules declare theirs here.
	Migrations []Migration

	// RateLimiters describe the named limiters the module creates on
	// Deps.RateLimits, for /ops/auth/rate-limits. A name declared twice
	// fails New naming both modules.
	RateLimiters []RateLimiter

	// Retention says how long the module's data is kept and what deletes
	// it, for /ops/retention; the built-in retention job deletes what has
	// a Delete function. [New] calls it once, before defining the jobs,
	// with the Deps Jobs receives.
	Retention func(d Deps) []Retention

	// Platform receives what New built for the whole app, after every
	// store and before any module's Routes. It is for gorbital's built-in
	// modules (see [Platform]). An error from it, such as an invalid
	// secret in the configuration, fails New as a configuration error. It
	// isn't called when the OpenAPI document is exported without a
	// database.
	Platform func(p *Platform) error
}

A Module is one feature of an app. Its package returns it from a function named Module, so declarations such as settings are created in that function's closure and used by Routes without a lookup by name:

func Module() gorbital.Module {
	var pageSize *settings.Setting[int]
	return gorbital.Module{
		Name:     "books",
		Settings: func(r *settings.Registry) { pageSize = settings.Int(r, "books.page_size", 20) },
		Routes:   func(r *gorbital.Router, d gorbital.Deps) { /* use pageSize */ },
	}
}
Example
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// call sends a request, as the signed-in user usr_1 when signedIn, and returns
// the status and the problem code, if any.
func call(h http.Handler, method, target string, signedIn bool) string {
	req := httptest.NewRequest(method, target, nil)
	if signedIn {
		req = req.WithContext(actor.With(req.Context(), actor.Actor{Kind: actor.KindUser, ID: "usr_1"}))
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	var p struct {
		Code string `json:"code"`
	}
	if json.Unmarshal(rec.Body.Bytes(), &p) == nil && p.Code != "" {
		return fmt.Sprintf("%d %s", rec.Code, p.Code)
	}
	return fmt.Sprint(rec.Code)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	books := gorbital.Module{
		Name: "books",
		Errors: []httpx.Mapping{
			{Err: ErrBookNotFound, Status: http.StatusNotFound, Code: "book_not_found", Detail: "no book has this ID"},
		},
		Permissions: []gorbital.Permission{
			{Name: "books.book.read", Description: "Read books", Roles: []string{"user"}},
		},
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			gorbital.Get(r.Group("/v1/books"), "/{id}", findBook)
		},
	}

	mux, api, mapper := newAPI()
	if err := gorbital.Mount(api, mapper, gorbital.Deps{}, books); err != nil {
		panic(err)
	}
	fmt.Println(call(mux, http.MethodGet, "/v1/books/bok_1", true))
	fmt.Println(call(mux, http.MethodGet, "/v1/books/bok_9", true))
}
Output:
200
404 book_not_found
Example (Jobs)
package main

import (
	"gorbital.dev/gorbital"
	"gorbital.dev/modules/jobs"
)

func main() {
	// Jobs runs before the job client exists: keep d and use it when a job
	// runs.
	digest := gorbital.Module{
		Name: "digests",
		Jobs: func(defs *jobs.Definitions, d gorbital.Deps) {
			// jobs.Define(defs, jobs.Definition[DigestArgs]{Name: "digests_send", Worker: &digestWorker{mailer: d.Mailer}, ...})
			_ = d.Mailer
		},
	}
	_ = digest
}
Example (Platform)
package main

import (
	"errors"

	"gorbital.dev/gorbital"
)

func main() {
	// A built-in module checks its configuration when the app starts: an
	// error is a configuration error (exit status 2 from Main).
	module := gorbital.Module{
		Name: "payments",
		Platform: func(p *gorbital.Platform) error {
			if p.Config.Production() && p.Config.Mail.ResendWebhookSecret.IsZero() {
				return errors.New("RESEND_WEBHOOK_SECRET is required in production")
			}
			return nil
		},
	}
	_ = module
}

type Option

type Option interface {
	// contains filtered or unexported methods
}

An Option configures New, Main and Migrate. Every option is one line in main.go that names what the app contains (ADR-0081).

Example
package main

import (
	"gorbital.dev/gorbital"
)

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

func main() {
	opts := []gorbital.Option{
		gorbital.WithName("shelfie"),
		gorbital.WithModules(modulesAll()...),
	}
	_ = opts
}

func WithAuth

func WithAuth(a Authenticator) Option

WithAuth sets the app's authenticator. Without one, requests have no actor, so only guard.Public() routes succeed, and New logs how many routes can't be reached.

Example
package main

import (
	"log/slog"
	"net/http"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
)

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

// headerAuth is an Authenticator trusting a gateway's header, for the
// examples.
type headerAuth struct{}

func (headerAuth) Middleware(*slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if id := r.Header.Get("X-Authenticated-User"); id != "" {
				r = r.WithContext(actor.With(r.Context(), actor.Actor{Kind: actor.KindUser, ID: id}))
			}
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	gorbital.Main(gorbital.WithAuth(headerAuth{}), gorbital.WithModules(modulesAll()...))
}

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the logger of the app and its modules. Without it, New builds one from APP_LOG_LEVEL and APP_LOG_FORMAT through the telemetry module, which also feeds the dev console and the hourly log archive.

Example
package main

import (
	"log/slog"
	"os"

	"gorbital.dev/gorbital"
)

func main() {
	gorbital.Main(gorbital.WithLogger(slog.New(slog.NewJSONHandler(os.Stderr, nil))))
}

func WithMailer

func WithMailer(s mail.Sender) Option

WithMailer sets the provider email is delivered through when MAIL_DELIVERY is provider, the only mode production allows. Modules don't send through it directly: Deps.Mailer queues each message as a job, and the mail worker delivers it through this sender, skipping suppressed addresses. In development, devmail and mailpit deliver over SMTP without it.

Example
package main

import (
	"context"
	"log/slog"

	"gorbital.dev/gorbital"
	"gorbital.dev/mail"
)

func main() {
	logged := mail.SenderFunc(func(ctx context.Context, m mail.Message) error {
		slog.InfoContext(ctx, "email", "subject", m.Subject)
		return nil
	})
	gorbital.Main(gorbital.WithMailer(logged))
}

func WithMailerFunc

func WithMailerFunc(open func(cfg Config) (mail.Sender, error)) Option

WithMailerFunc sets the email provider like WithMailer, built from the loaded configuration, such as an API key in Config.Mail. An error from open fails New as a configuration error.

Example
package main

import (
	"context"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
	"gorbital.dev/mail"
)

// newResendSender stands in for resend.New.
func newResendSender(config.Secret) mail.Sender {
	return mail.SenderFunc(func(context.Context, mail.Message) error { return nil })
}

func main() {
	gorbital.Main(gorbital.WithMailerFunc(func(cfg gorbital.Config) (mail.Sender, error) {
		return newResendSender(cfg.Mail.ResendAPIKey), nil
	}))
}

func WithMiddleware

func WithMiddleware(middleware ...func(http.Handler) http.Handler) Option

WithMiddleware adds middleware that runs on every request after the built-in stack: after authentication, rate limits and idempotency keys, so it can read the actor. Middleware added by WithMiddleware and WithMiddlewareFunc runs in the order the options are given.

Example
package main

import (
	"net/http"

	"gorbital.dev/gorbital"
)

// requireClientVersion refuses clients older than min, for the examples.
func requireClientVersion(min string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if v := r.Header.Get("X-Client-Version"); v != "" && v < min {
				http.Error(w, "upgrade the app", http.StatusUpgradeRequired)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	gorbital.Main(gorbital.WithMiddleware(requireClientVersion("2.4.0")))
}

func WithMiddlewareFunc

func WithMiddlewareFunc(build func(d Deps) func(http.Handler) http.Handler) Option

WithMiddlewareFunc adds middleware like WithMiddleware, built with the app's dependencies once they exist, such as a middleware that reads a runtime setting or writes to the database.

Example
package main

import (
	"net/http"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
)

func main() {
	gorbital.Main(gorbital.WithMiddlewareFunc(func(d gorbital.Deps) func(http.Handler) http.Handler {
		logger := d.Logger
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if a, ok := actor.From(r.Context()); ok {
					logger.DebugContext(r.Context(), "request", "actor", a.ID)
				}
				next.ServeHTTP(w, r)
			})
		}
	}))
}

func WithMigrations

func WithMigrations(fsys fs.FS) Option

WithMigrations sets the app's own goose migrations, usually the embedded files of its db/migrations package. Migrate merges them with the library's and the modules' migrations by version; New reports pending ones; the dev console lists them.

Example
package main

import (
	"embed"

	"gorbital.dev/gorbital"
)

// migrationFiles stands in for an app's db/migrations package.
var migrationFiles embed.FS

func main() {
	// db/migrations/migrations.go:
	//
	//	//go:embed *.sql
	//	var FS embed.FS
	gorbital.Main(gorbital.WithMigrations(migrationFiles))
}

func WithModules

func WithModules(modules ...Module) Option

WithModules adds modules to the app, after those added before. Order matters only for reading: each module's routes, settings and permissions are its own, and a duplicate fails New naming both modules.

Example
package main

import (
	"gorbital.dev/gorbital"
)

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

func main() {
	gorbital.Main(gorbital.WithModules(modulesAll()...))
}

func WithName

func WithName(name string) Option

WithName sets the app's name, used as the service name in logs, traces and metrics, the OpenAPI document's title and the database connections' application name. Without it, the name is the last element of the main module's path, such as shelfie for example.com/shelfie.

Example
package main

import (
	"gorbital.dev/gorbital"
)

// modulesAll stands in for the generated internal/modules.All.
func modulesAll() []gorbital.Module { return nil }

func main() {
	gorbital.Main(gorbital.WithName("shelfie"), gorbital.WithModules(modulesAll()...))
}

func WithScope added in v0.3.0

func WithScope(s Scope, a ScopeAuthorizer) Option

WithScope gives the app its tenancy: what a tenant is called, how its IDs look, which roles it has, and how a request's tenant reaches the database, with the authorizer that decides who may act in one (ADR-0088). Every route with guard.Scope asks it.

An app that mounts gorbital.dev/gorbital/orgshttp doesn't need this: the module sets the scope itself. Use it for an app with its own membership tables:

gorbital.WithScope(gorbital.Scope{
	Name:         "merchant",
	PathParam:    "merchantId",
	NotFoundCode: "merchant_not_found",
	ValidID:      merchants.ValidID,
	Roles:        merchants.Roles,
	Session:      postgres.WithScope,
}, merchants.NewAuthorizer(db))

New fails when a is nil, the scope is invalid, or a module also sets one: an app has one source of truth for membership.

Example
package main

import (
	"context"
	"fmt"
	"strings"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/modules/postgres"
)

// merchantScope is the tenancy of an app whose customers are merchants:
// its own word, its own IDs, its own roles.
func merchantScope() gorbital.Scope {
	return gorbital.Scope{
		Name:         "merchant",
		PathParam:    "merchantId",
		NotFoundCode: "merchant_not_found",
		ValidID:      func(id string) bool { return strings.HasPrefix(id, "mch_") },
		Roles: []gorbital.ScopeRole{
			{Name: "owner", Description: "Runs the merchant"},
			{Name: "manager", Description: "Manages staff and orders"},
			{Name: "courier", Description: "Delivers orders"},
		},
		Session: postgres.WithScope,
	}
}

// members is a scope authorizer over the app's own membership table.
type members map[string]string

func (m members) AuthorizeScope(ctx context.Context, scopeID, permission string) (context.Context, error) {
	a, ok := actor.From(ctx)
	if !ok {
		return ctx, actor.ErrUnauthenticated
	}
	role, member := m[scopeID+"/"+a.ID]
	if !member {
		return ctx, gorbital.ErrScopeNotFound
	}
	if role != "owner" && permission == "orders.order.refund" {
		return ctx, actor.ErrForbidden
	}
	a.OrgID, a.Permissions = scopeID, []string{permission}
	return actor.With(ctx, a), nil
}

func main() {
	// An app with its own membership tables gives gorbital the vocabulary
	// and the authorizer; guard.Scope asks it on every scope route.
	opts := []gorbital.Option{
		gorbital.WithName("shop-api"),
		gorbital.WithScope(merchantScope(), members{}),
	}
	fmt.Println(len(opts), "options")
}
Output:
2 options

func WithScopeWords added in v0.3.0

func WithScopeWords(s Scope) Option

WithScopeWords says what the app calls its tenant when a module owns membership and renames it, such as orgshttp.Module(auth, orgshttp.ScopeName("merchant", "merchants", "merchantId")). The module still sets the scope and its authorizer; this only tells the parts of New that run before any module's Platform does — the route checks of guard.Scope, and the OpenAPI document `openapi --dir` exports without connecting to anything — which words to expect:

gorbital.WithScopeWords(gorbital.Scope{
	Name:         "merchant",
	PathParam:    "merchantId",
	NotFoundCode: "merchant_not_found",
})

It carries no authorizer, so it is not a second scope: an app whose membership is its own uses WithScope instead, which says the same words and more.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	// An app whose membership is a module's, mounted under its own words:
	// the module still sets the scope, and this says the words before it
	// runs, for guard.Scope's route checks and the exported OpenAPI
	// document.
	opts := []gorbital.Option{
		gorbital.WithName("shop-api"),
		gorbital.WithScopeWords(gorbital.Scope{
			Name:         "merchant",
			PathParam:    "merchantId",
			NotFoundCode: "merchant_not_found",
		}),
	}
	fmt.Println(len(opts), "options")
}
Output:
2 options

func WithStack

func WithStack(build func(s Stack) []func(http.Handler) http.Handler) Option

WithStack replaces the order of the built-in middleware stack: build receives every built-in step and returns the steps to run, outermost first. Leaving out Recover or Auth is allowed, and logged as a warning when New builds the handler. WithMiddleware still runs after the returned steps.

Example
package main

import (
	"net/http"

	"gorbital.dev/gorbital"
)

// requireClientVersion refuses clients older than min, for the examples.
func requireClientVersion(min string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if v := r.Header.Get("X-Client-Version"); v != "" && v < min {
				http.Error(w, "upgrade the app", http.StatusUpgradeRequired)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	gorbital.Main(gorbital.WithStack(func(s gorbital.Stack) []func(http.Handler) http.Handler {
		// Refuse old clients before anything is logged or authenticated.
		return append([]func(http.Handler) http.Handler{s.Recover, requireClientVersion("2.4.0")}, s.Default()[1:]...)
	}))
}

func WithStorage

func WithStorage(s storage.Store) Option

WithStorage sets the app's file storage, passed to modules as Deps.Storage. Without it, New opens the local driver for STORAGE_DRIVER=local (the default in development) and refuses the S3-compatible drivers, whose client gorbital doesn't import: pass one, built from Config.Storage with WithStorageFunc.

Example
package main

import (
	"gorbital.dev/gorbital"
	"gorbital.dev/modules/storage"
)

func main() {
	var bucket storage.Store // such as a store from gorbital.dev/modules/storage/s3
	gorbital.Main(gorbital.WithStorage(bucket))
}

func WithStorageFunc

func WithStorageFunc(open func(cfg Config) (storage.Store, error)) Option

WithStorageFunc sets the app's file storage like WithStorage, built from the loaded configuration, as main.go needs for a store whose settings come from STORAGE_* variables. An error from open fails New as a configuration error. A nil store with a nil error keeps the built-in choice, so one function can serve every STORAGE_DRIVER: the local driver for local, and a configuration error naming this option for the others.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/storage"
)

func main() {
	gorbital.Main(gorbital.WithStorageFunc(func(cfg gorbital.Config) (storage.Store, error) {
		// With gorbital.dev/modules/storage/s3:
		//	return s3.New(s3.Config{Driver: cfg.Storage.Driver, Endpoint: cfg.Storage.Endpoint, ...})
		return nil, fmt.Errorf("STORAGE_DRIVER=%s isn't set up in this app", cfg.Storage.Driver)
	}))
}

type OrgAuthorizer

type OrgAuthorizer interface {
	AuthorizeOrg(ctx context.Context, orgID, permission string) (context.Context, error)
}

An OrgAuthorizer decides whether the actor of a request may act in an organisation.

Organisations are one Scope (ADR-0088): prefer ScopeAuthorizer, whose AuthorizeScope has exactly these semantics. This interface, Platform.SetOrgAuthorizer and guard.OrgMember keep working for all of v0.x and carry no Deprecated marker, so staticcheck does not fail the build of an app that already uses them.

AuthorizeOrg checks that the actor in ctx is a member of orgID (a user, or a service account of that organisation authenticated by its API key) whose role grants permission, and returns a context whose actor acts in the organisation: actor.Actor.OrgID set, and Permissions those of the role, limited by an API key's scopes. Its errors are ScopeAuthorizer's, with orgs.ErrOrgNotFound accepted in place of ErrScopeNotFound.

Example
package main

import (
	"context"
	"fmt"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/modules/orgs"
)

// memberships is an organisation authorizer over a fixed list of members,
// holding every permission they ask for.
type memberships map[string]string

func (m memberships) AuthorizeOrg(ctx context.Context, orgID, permission string) (context.Context, error) {
	a, ok := actor.From(ctx)
	if !ok {
		return ctx, actor.ErrUnauthenticated
	}
	if _, member := m[orgID+"/"+a.ID]; !member {
		return ctx, orgs.ErrOrgNotFound
	}
	a.OrgID, a.Permissions = orgID, []string{permission}
	return actor.With(ctx, a), nil
}

func main() {
	var authorizer gorbital.OrgAuthorizer = memberships{"org_1/usr_ada": orgs.RoleOwner}
	ada := actor.With(context.Background(), actor.Actor{Kind: actor.KindUser, ID: "usr_ada"})
	ctx, err := authorizer.AuthorizeOrg(ada, "org_1", "invoices.invoice.read")
	a, _ := actor.From(ctx)
	fmt.Println(a.OrgID, err)
	_, err = authorizer.AuthorizeOrg(ada, "org_2", "invoices.invoice.read")
	fmt.Println(err)
}
Output:
org_1 <nil>
orgs: organisation not found

type Permission

type Permission struct {
	Name        string
	Description string
	// Roles are the platform roles that hold the permission, such as
	// "user". A role the app doesn't declare grants nothing.
	Roles []string
	// ScopeRoles are the scope roles that hold the permission, such as
	// "owner", "admin" and "member" for organisations, or whatever the
	// app's [Scope] declares: a member holds it only while acting in a
	// scope, through guard.Scope. A permission with ScopeRoles is a scope
	// permission: it is declared in the scope catalog
	// ([Platform.OrgPermissions]), not the platform's, and can't have
	// Roles too.
	ScopeRoles []string

	// OrgRoles is the v0.2 name of ScopeRoles, still honoured: prefer
	// ScopeRoles in new code, and set one or the other, never both. It
	// carries no Deprecated marker on purpose — v0.3.0 is a patch, and a
	// marker would make staticcheck fail the build of every app that
	// already uses the name, including the copies of sign-in and
	// organisations orb new wrote for them.
	//
	// OrgRoles are the organisation roles that hold the permission, such
	// as "owner", "admin" and "member" (ADR-0023, ADR-0048): a member holds
	// it only while acting in an organisation, through guard.OrgMember. A
	// permission with OrgRoles is an organisation permission: it is
	// declared in the organisation catalog ([Platform.OrgPermissions]), not
	// the platform's, and can't have Roles too.
	OrgRoles []string
}

A Permission is a permission a module checks, such as "books.book.write".

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/auth"
)

func main() {
	write := gorbital.Permission{
		Name:        "books.book.write",
		Description: "Add, change and remove books",
		Roles:       []string{"user"},
	}
	catalog := auth.NewCatalog()
	if err := gorbital.Declare(gorbital.Declarations{Permissions: catalog}, gorbital.Module{Name: "books", Permissions: []gorbital.Permission{write}}); err != nil {
		panic(err)
	}
	fmt.Println(len(catalog.AllPermissions()), catalog.AllPermissions()[0].Name)
}
Output:
1 books.book.write

type PermissionDeclarer

type PermissionDeclarer interface {
	Permission(name, description string)
}

A PermissionDeclarer declares permissions. *auth.Catalog implements it.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

// allowList is a PermissionDeclarer that only records names, for an app
// that checks permissions without *auth.Catalog.
type allowList []string

func (l *allowList) Permission(name, _ string) { *l = append(*l, name) }

func main() {
	var declared allowList
	books := gorbital.Module{Name: "books", Permissions: []gorbital.Permission{{Name: "books.book.read"}, {Name: "books.book.write"}}}
	if err := gorbital.Declare(gorbital.Declarations{Permissions: &declared}, books); err != nil {
		panic(err)
	}
	fmt.Println(declared)
}
Output:
[books.book.read books.book.write]

type Platform

type Platform struct {
	// Config is the app's configuration.
	Config Config
	// Name is the app's name ([WithName]).
	Name string
	// StartedAt is when New started building the app.
	StartedAt time.Time
	// InstanceID identifies this process in /ops/releases and in request
	// metrics.
	InstanceID string
	// Health runs the readiness checks of /readyz.
	Health *health.Checker
	// Jobs manages job definitions, runs and queues.
	Jobs *jobs.Manager
	// MailSender are the runtime settings that fill every email's sender.
	MailSender mail.Defaults
	// Migrations are every migration Migrate applies: the library's, the
	// modules' and the app's, merged.
	Migrations fs.FS
	// Authenticator is the app's authenticator ([WithAuth]), or nil.
	Authenticator Authenticator
	// Permissions is the platform catalog: every module's platform
	// permissions and a role for each role they name, with the permissions
	// the modules grant it and the second factor the authenticator requires
	// for it.
	Permissions *auth.Catalog
	// OrgPermissions is the organisation catalog: every module's
	// organisation permissions ([Permission.OrgRoles]) and a role for each
	// organisation role they name, with the permissions the modules grant
	// it. It isn't frozen, so the organisations module can require a second
	// factor for roles before freezing it.
	OrgPermissions *auth.Catalog
	// contains filtered or unexported fields
}

A Platform is what New builds for the app as a whole, beyond Deps: the job manager, the instance's identity and health, the configuration, and what every module declared about its data and rate limits. The operations API (gorbital.dev/gorbital/opshttp) reports on them and the mail events module (gorbital.dev/gorbital/mailevents) reads its webhook secret from the configuration; they receive it through Module.Platform.

Platform is for gorbital's built-in modules. App modules use Deps: a Platform's fields follow what the built-in modules need, and grow with them (ADR-0083).

Example

A built-in module keeps the Platform New gives it, and uses it in its routes; app modules use Deps.

package main

import (
	"context"

	"gorbital.dev/gorbital"
)

func main() {
	module := func() gorbital.Module {
		var platform *gorbital.Platform
		return gorbital.Module{
			Name:     "status",
			Platform: func(p *gorbital.Platform) error { platform = p; return nil },
			Routes: func(r *gorbital.Router, d gorbital.Deps) {
				gorbital.Get(r, "/ops/status", func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) {
					return &struct{ Body string }{Body: platform.Name + " " + platform.InstanceID}, nil
				})
			},
		}
	}
	_ = gorbital.WithModules(module())
}

func (*Platform) Authenticate

func (p *Platform) Authenticate(ctx context.Context, r *http.Request) (actor.Actor, bool)

Authenticate runs the app's authentication step again for r, such as a long-running stream checking that its session hasn't ended: the authenticator (WithAuth) and, in development, the dev console's operator on /ops/. It sees r's headers and client address, but none of the values in r's context, so an actor set before doesn't carry over. It returns the actor the step resolved, and false when the request isn't authenticated any more.

Example
package main

import (
	"context"
	"net/http"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
)

func main() {
	// A server-sent events stream checks every few seconds that the
	// request's session hasn't ended.
	stillSignedIn := func(ctx context.Context, p *gorbital.Platform, r *http.Request) (context.Context, bool) {
		a, ok := p.Authenticate(ctx, r)
		if !ok {
			return ctx, false
		}
		return actor.With(ctx, a), true
	}
	_ = stillSignedIn
}

func (*Platform) OnShutdown

func (p *Platform) OnShutdown(fn func())

OnShutdown adds fn to what runs when App.Run starts shutting down, before the server stops taking requests, such as closing streams that would hold it open.

Example
package main

import (
	"gorbital.dev/gorbital"
)

func main() {
	// Long-lived streams end when the app starts shutting down, so the
	// server doesn't wait for them.
	stop := make(chan struct{})
	module := gorbital.Module{
		Name: "feed",
		Platform: func(p *gorbital.Platform) error {
			p.OnShutdown(func() { close(stop) })
			return nil
		},
	}
	_ = module
}

func (*Platform) RateLimiters

func (p *Platform) RateLimiters() []RateLimiter

RateLimiters returns every named rate limiter of the app: the built-in one of the RateLimit step (auth_ip), those modules declare in Module.RateLimiters, in module order, then those guard.RateLimit creates, by name.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	// GET /ops/auth/rate-limits lists every limiter, so operators know what
	// they can reset.
	list := func(p *gorbital.Platform) {
		for _, l := range p.RateLimiters() {
			fmt.Printf("%s (keys: %s): %s\n", l.Name, l.Keys, l.Description)
		}
	}
	_ = list
}

func (*Platform) Retention

func (p *Platform) Retention() []Retention

Retention returns how long each kind of data is kept: what gorbital builds, then each module's Module.Retention, in module order.

Example
package main

import (
	"context"
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	// GET /ops/retention reads each kind of data's setting.
	report := func(ctx context.Context, p *gorbital.Platform) {
		for _, r := range p.Retention() {
			fmt.Printf("%s: kept %s (%s)\n", r.Data, r.Setting.Get(ctx), r.Setting.Key())
		}
	}
	_ = report
}

func (*Platform) SetOrgAuthorizer

func (p *Platform) SetOrgAuthorizer(a OrgAuthorizer) error

SetOrgAuthorizer makes a the app's organisation authorizer.

Prefer Platform.SetScope with a Scope, which lets the app name its own tenancy and validate its own IDs. This call is Platform.SetScope with the default organisation scope: the v0.1 and v0.2 vocabulary, and no ID validation of its own, so a malformed ID is refused by the authorizer exactly as an unknown one is.

Example
package main

import (
	"context"
	"fmt"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/modules/orgs"
)

// memberships is an organisation authorizer over a fixed list of members,
// holding every permission they ask for.
type memberships map[string]string

func (m memberships) AuthorizeOrg(ctx context.Context, orgID, permission string) (context.Context, error) {
	a, ok := actor.From(ctx)
	if !ok {
		return ctx, actor.ErrUnauthenticated
	}
	if _, member := m[orgID+"/"+a.ID]; !member {
		return ctx, orgs.ErrOrgNotFound
	}
	a.OrgID, a.Permissions = orgID, []string{permission}
	return actor.With(ctx, a), nil
}

func main() {
	// An organisations module makes itself the authorizer guard.OrgMember
	// asks, once the app is built. gorbital.dev/gorbital/orgshttp does.
	orgsModule := gorbital.Module{
		Name: "orgs",
		Platform: func(p *gorbital.Platform) error {
			return p.SetOrgAuthorizer(memberships{})
		},
	}
	fmt.Println(orgsModule.Name)
}
Output:
orgs

func (*Platform) SetScope added in v0.3.0

func (p *Platform) SetScope(s Scope, a ScopeAuthorizer) error

SetScope makes s the app's scope and a its authorizer, which every route with guard.Scope asks. It is for a module that owns membership, such as gorbital.dev/gorbital/orgshttp, called from Module.Platform. An app that authorizes scopes itself uses WithScope instead.

It returns an error when a is nil, the scope is invalid, or the app already has one.

Example
package main

import (
	"context"
	"fmt"
	"strings"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/modules/postgres"
)

// merchantScope is the tenancy of an app whose customers are merchants:
// its own word, its own IDs, its own roles.
func merchantScope() gorbital.Scope {
	return gorbital.Scope{
		Name:         "merchant",
		PathParam:    "merchantId",
		NotFoundCode: "merchant_not_found",
		ValidID:      func(id string) bool { return strings.HasPrefix(id, "mch_") },
		Roles: []gorbital.ScopeRole{
			{Name: "owner", Description: "Runs the merchant"},
			{Name: "manager", Description: "Manages staff and orders"},
			{Name: "courier", Description: "Delivers orders"},
		},
		Session: postgres.WithScope,
	}
}

// members is a scope authorizer over the app's own membership table.
type members map[string]string

func (m members) AuthorizeScope(ctx context.Context, scopeID, permission string) (context.Context, error) {
	a, ok := actor.From(ctx)
	if !ok {
		return ctx, actor.ErrUnauthenticated
	}
	role, member := m[scopeID+"/"+a.ID]
	if !member {
		return ctx, gorbital.ErrScopeNotFound
	}
	if role != "owner" && permission == "orders.order.refund" {
		return ctx, actor.ErrForbidden
	}
	a.OrgID, a.Permissions = scopeID, []string{permission}
	return actor.With(ctx, a), nil
}

func main() {
	// A module that owns membership sets the scope once the app is built.
	// gorbital.dev/gorbital/orgshttp does this for organisations.
	merchants := gorbital.Module{
		Name: "merchants",
		Platform: func(p *gorbital.Platform) error {
			return p.SetScope(merchantScope(), members{})
		},
	}
	fmt.Println(merchants.Name)
}
Output:
merchants

func (*Platform) SignInMethods

func (p *Platform) SignInMethods() []SignInMethod

SignInMethods returns the sign-in methods the app's authenticator (WithAuth) reports for the configuration, through its optional method

SignInMethods(cfg gorbital.Config) []gorbital.SignInMethod

It returns an empty list without an authenticator or when the authenticator doesn't report its methods. gorbital.dev/gorbital/authhttp reports every method a v0.1 app listed.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	// GET /ops/auth/providers lists what the authenticator reports, and for
	// each method that is off, what turns it on.
	report := func(p *gorbital.Platform) {
		for _, m := range p.SignInMethods() {
			if m.Enabled {
				fmt.Printf("on: %s %s\n", m.Name, m.Detail)
			} else {
				fmt.Printf("off: %s, set %v (%s)\n", m.Name, m.Missing, m.Guide)
			}
		}
	}
	_ = report
}

type RateLimiter

type RateLimiter struct {
	// Name is the limiter's name, as passed to ratelimitpg.Store.Limiter,
	// such as "auth_login". Names are unique in an app.
	Name string
	// Keys says what a key is, such as "client IP address" or "actor ID",
	// so operators know what to reset.
	Keys string
	// Description says what the limiter protects.
	Description string
}

A RateLimiter describes a named rate limiter a module creates on Deps.RateLimits, for GET /ops/auth/rate-limits, where operators reset a key's budget. Limiters of guard.RateLimit are listed without one.

Example
package main

import (
	"context"
	"time"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/ratelimitpg"
	"gorbital.dev/ratelimit"
)

func main() {
	// A module creating its own limiter on the shared store declares it, so
	// /ops/auth/rate-limits lists it.
	var limiter *ratelimitpg.Limiter
	module := gorbital.Module{
		Name:         "imports",
		RateLimiters: []gorbital.RateLimiter{{Name: "imports_uploads", Keys: "user ID", Description: "CSV uploads per user"}},
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			if d.RateLimits != nil {
				var err error
				limiter, err = d.RateLimits.Limiter("imports_uploads", func(context.Context) ratelimit.Limit {
					return ratelimit.Per(10, time.Hour)
				})
				if err != nil {
					panic(err)
				}
			}
		},
	}
	_, _ = module, limiter
}

type Retention

type Retention struct {
	// Data names the data, such as "audit_events", in /ops/retention, logs
	// and retention.purged audit events. Names are unique in an app.
	Data string
	// Setting is the runtime setting that holds how long the data is kept.
	Setting *settings.Setting[time.Duration]
	// Delete removes up to limit rows older than before and returns how
	// many it removed. The built-in retention job calls it every day, with
	// the time Setting's value ago ([RetentionJob]).
	Delete func(ctx context.Context, before time.Time, limit int) (int64, error)
	// Job is the name of the job that deletes the data, when the module
	// deletes it with a job of its own, such as "auth_cleanup".
	Job string
	// EnforcedBy says what deletes the data when no job does, such as
	// "each instance, when it starts".
	EnforcedBy string
	// Oldest returns when the oldest stored row was written, and false when
	// there is none. Leave it nil when that can't be read cheaply.
	Oldest func(ctx context.Context) (time.Time, bool, error)
}

A Retention is how long one kind of a module's data is kept and what deletes it, listed by GET /ops/retention. Exactly one of Delete, Job and EnforcedBy says what deletes the data.

Example
package main

import (
	"context"
	"time"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/settings"
)

func main() {
	// A module keeps notes for a runtime setting's duration; the built-in
	// retention job deletes older ones every day, and /ops/retention lists
	// the policy.
	var keep *settings.Setting[time.Duration]
	module := gorbital.Module{
		Name: "notes",
		Settings: func(r *settings.Registry) {
			keep = settings.Duration(r, "notes.retention", 90*24*time.Hour,
				settings.Describe("How long deleted notes are kept."), settings.Group("retention"))
		},
		Retention: func(d gorbital.Deps) []gorbital.Retention {
			return []gorbital.Retention{{
				Data:    "deleted_notes",
				Setting: keep,
				Delete: func(ctx context.Context, before time.Time, limit int) (int64, error) {
					tag, err := d.DB.Exec(ctx, `DELETE FROM notes WHERE id IN (
						SELECT id FROM notes WHERE deleted_at < $1 LIMIT $2)`, before, limit)
					return tag.RowsAffected(), err
				},
			}}
		},
	}
	_ = gorbital.WithModules(module)
}

type RouteOption

type RouteOption = route.Option

A RouteOption configures a route, or every route of a group. Options of a group apply first, then the route's own.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	// Options on a group apply to its routes first; a route's own options
	// come after and win.
	routesExample(func(r *gorbital.Router) {
		books := r.Group("/v1/books", gorbital.Tags("Books"), gorbital.Summary("A books operation"))
		gorbital.Get(books, "/{id}", findBook, gorbital.Summary("Get a book"))
	}, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get a book" tags=[Books] secured=true deprecated=false

func AuthenticateAfterInput

func AuthenticateAfterInput() RouteOption

AuthenticateAfterInput checks the route's authenticated actor after Huma has parsed and validated the input, just before the handler, instead of before parsing. The route still requires sign-in, in the OpenAPI document and on every request: only the order of refusals changes, so a request without credentials and with an invalid body gets 422 validation_failed (or 400) rather than 401 unauthenticated.

It exists for code that must keep v0.1's order of responses, such as sign-in's endpoints (gorbital.dev/gorbital/authhttp) and the copies of them an app holds; new routes don't need it. Registration fails when the route is public or has guards, which run before input parsing and would see a request nobody authenticated.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookBody struct{ Body Book }

type newBookInput struct {
	Body struct {
		Title string `json:"title" minLength:"1"`
	}
}

func addBook(_ context.Context, in *newBookInput) (*bookBody, error) {
	return &bookBody{Body: Book{ID: "bok_2", Title: in.Body.Title}}, nil
}

func main() {
	mux, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{Name: "books", Routes: func(r *gorbital.Router, d gorbital.Deps) {
		gorbital.Post(r, "/v1/books", addBook, gorbital.AuthenticateAfterInput())
	}})
	if err != nil {
		panic(err)
	}
	post := func(body string) string {
		req := httptest.NewRequest(http.MethodPost, "/v1/books", strings.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		var p struct {
			Code string `json:"code"`
		}
		_ = json.Unmarshal(rec.Body.Bytes(), &p)
		return fmt.Sprintf("%d %s", rec.Code, p.Code)
	}
	// Without credentials: invalid input is answered first, valid input is
	// refused.
	fmt.Println(post(`{"title":""}`))
	fmt.Println(post(`{"title":"Dune"}`))
}
Output:
422 validation_failed
401 unauthenticated

func Customize

func Customize(fn func(api huma.API, op *huma.Operation)) RouteOption

Customize changes the Huma operation before it is registered, for what the other options don't set, such as a response's media types for a streaming route, Huma's body limit, or schemas added to the API's registry. fn receives the API and the operation as the other options, guards and deny by default have built it, and runs once, at registration. Customize options run in the order given.

fn can't change the method, path, operation ID, security requirements or operation middleware: registration fails when it does, so a route can't leave deny by default or its guards behind.

Example
stream := func(context.Context, *struct{}) (*huma.StreamResponse, error) {
	return &huma.StreamResponse{Body: func(hctx huma.Context) {
		hctx.SetHeader("Content-Type", "text/event-stream")
		_, _ = hctx.BodyWriter().Write([]byte("event: tick\ndata: {}\n\n"))
	}}, nil
}
_, api, mapper := newAPI()
err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
	Name: "clock",
	Routes: func(r *gorbital.Router, d gorbital.Deps) {
		gorbital.Get(r, "/v1/ticks", stream, gorbital.Customize(func(api huma.API, op *huma.Operation) {
			// Document the event stream and the schema of its events.
			tick := api.OpenAPI().Components.Schemas.Schema(reflect.TypeFor[Tick](), true, "")
			op.Responses = map[string]*huma.Response{"200": {
				Description: "Server-sent events: tick, with a Tick",
				Content:     map[string]*huma.MediaType{"text/event-stream": {Schema: &huma.Schema{Type: huma.TypeString, Description: tick.Ref}}},
			}}
		}))
	},
})
fmt.Println(err)
for mediaType := range api.OpenAPI().Paths["/v1/ticks"].Get.Responses["200"].Content {
	fmt.Println(mediaType)
}

// What protects a route can't be customized away.
err = gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
	Name: "leaky",
	Routes: func(r *gorbital.Router, d gorbital.Deps) {
		gorbital.Get(r, "/v1/secrets", stream, gorbital.Customize(func(_ huma.API, op *huma.Operation) { op.Security = nil }))
	},
})
fmt.Println(err)
Output:
<nil>
text/event-stream
gorbital: module "leaky": GET /v1/secrets: a Customize option can't change the method, path, operation ID, security or middleware of a route

func Deprecated

func Deprecated() RouteOption

Deprecated marks the route deprecated in the OpenAPI document.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.Deprecated())
	}, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get v1 books by ID" tags=[] secured=true deprecated=true

func Description

func Description(markdown string) RouteOption

Description sets the OpenAPI description, in Markdown.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{Name: "books", Routes: func(r *gorbital.Router, d gorbital.Deps) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.Description("Returns one of **your** books."))
	}})
	if err != nil {
		panic(err)
	}
	fmt.Println(api.OpenAPI().Paths["/v1/books/{id}"].Get.Description)
}
Output:
Returns one of **your** books.

func Errors

func Errors(statuses ...int) RouteOption

Errors documents error statuses the route returns, in addition to those its guards document.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{Name: "books", Routes: func(r *gorbital.Router, d gorbital.Deps) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.Errors(http.StatusNotFound))
	}})
	if err != nil {
		panic(err)
	}
	responses := api.OpenAPI().Paths["/v1/books/{id}"].Get.Responses
	fmt.Println(responses["401"] != nil, responses["404"] != nil)
}
Output:
true true

func OperationID

func OperationID(id string) RouteOption

OperationID sets the operation ID. Without it, the ID is the module's name followed by the one Huma generates from the method and path, such as "books-post-v1-books". Operation IDs are public API: client generators name their functions after them.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.OperationID("books-get"))
	}, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get summary="Get v1 books by ID" tags=[] secured=true deprecated=false

func Status

func Status(code int) RouteOption

Status sets the success status, such as http.StatusCreated.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// call sends a request, as the signed-in user usr_1 when signedIn, and returns
// the status and the problem code, if any.
func call(h http.Handler, method, target string, signedIn bool) string {
	req := httptest.NewRequest(method, target, nil)
	if signedIn {
		req = req.WithContext(actor.With(req.Context(), actor.Actor{Kind: actor.KindUser, ID: "usr_1"}))
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	var p struct {
		Code string `json:"code"`
	}
	if json.Unmarshal(rec.Body.Bytes(), &p) == nil && p.Code != "" {
		return fmt.Sprintf("%d %s", rec.Code, p.Code)
	}
	return fmt.Sprint(rec.Code)
}

type bookIDInput struct {
	ID string `path:"id"`
}

func removeBook(context.Context, *bookIDInput) (*struct{}, error) { return nil, nil }

func main() {
	mux, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{Name: "books", Routes: func(r *gorbital.Router, d gorbital.Deps) {
		gorbital.Delete(r, "/v1/books/{id}", removeBook, gorbital.Status(http.StatusNoContent))
	}})
	if err != nil {
		panic(err)
	}
	fmt.Println(call(mux, http.MethodDelete, "/v1/books/bok_1", true))
}
Output:
204

func Summary

func Summary(s string) RouteOption

Summary sets the OpenAPI summary. Without it, Huma generates one from the method and path.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.Summary("Get a book"))
	}, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get a book" tags=[] secured=true deprecated=false

func Tags

func Tags(tags ...string) RouteOption

Tags sets the OpenAPI tags. Without it, a route has none.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

// routesExample mounts one module with routes and describes an operation.
func routesExample(routes func(r *gorbital.Router), method, path string) {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name:   "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) { routes(r) },
	})
	if err != nil {
		panic(err)
	}
	describe(api, method, path)
}

func main() {
	routesExample(func(r *gorbital.Router) {
		gorbital.Get(r, "/v1/books/{id}", findBook, gorbital.Tags("Books", "Library"))
	}, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get v1 books by ID" tags=[Books Library] secured=true deprecated=false

func Timeout

func Timeout(d time.Duration) RouteOption

Timeout gives a route a shorter deadline than the app's request timeout (APP_REQUEST_TIMEOUT): after d, a handler that hasn't started its response gets 503 request_timeout, and its context is cancelled. A context deadline can only be shortened, so a longer d has no effect: raise APP_REQUEST_TIMEOUT, or leave the Timeout step out with WithStack, for routes that need longer. Streaming responses that have started aren't cut off (timeout.New).

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/gorbital/guard"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// call sends a request, as the signed-in user usr_1 when signedIn, and returns
// the status and the problem code, if any.
func call(h http.Handler, method, target string, signedIn bool) string {
	req := httptest.NewRequest(method, target, nil)
	if signedIn {
		req = req.WithContext(actor.With(req.Context(), actor.Actor{Kind: actor.KindUser, ID: "usr_1"}))
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	var p struct {
		Code string `json:"code"`
	}
	if json.Unmarshal(rec.Body.Bytes(), &p) == nil && p.Code != "" {
		return fmt.Sprintf("%d %s", rec.Code, p.Code)
	}
	return fmt.Sprint(rec.Code)
}

func main() {
	mux, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name: "reports",
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			slow := func(ctx context.Context, _ *struct{}) (*struct{}, error) {
				<-ctx.Done() // a query that respects its context stops here
				return nil, ctx.Err()
			}
			gorbital.Get(r, "/v1/reports/yearly", slow, guard.Public(), gorbital.Timeout(20*time.Millisecond))
		},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(call(mux, http.MethodGet, "/v1/reports/yearly", false))
}
Output:
503 request_timeout

func Use

func Use(middlewares ...func(http.Handler) http.Handler) RouteOption

Use adds middleware to a route, or to every route of a group. Middleware runs in the order given, after the module's and the group's middleware and before the route's guards and input parsing (ADR-0082). Any standard middleware works:

books := r.Group("/v1/books", gorbital.Use(requireClientVersion("2.4.0")))

A route can't remove middleware its group added: put routes that need different middleware in their own group.

Route middleware needs an API built on Huma's humago adapter, as openapi.New builds it.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	// requireClientVersion refuses mobile apps older than min.
	requireClientVersion := func(min string) func(http.Handler) http.Handler {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if r.Header.Get("X-App-Version") < min {
					httpx.WriteProblem(w, r, httpx.NewProblem(http.StatusUpgradeRequired, "app_outdated", "update the app to continue"))
					return
				}
				next.ServeHTTP(w, r)
			})
		}
	}

	mux, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name: "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			books := r.Group("/v1/books", gorbital.Use(requireClientVersion("2.4.0")))
			gorbital.Get(books, "/{id}", findBook)
		},
	})
	if err != nil {
		panic(err)
	}
	for _, version := range []string{"2.3.9", "2.4.0"} {
		req := httptest.NewRequest(http.MethodGet, "/v1/books/bok_1", nil)
		req.Header.Set("X-App-Version", version)
		req = req.WithContext(actor.With(req.Context(), actor.Actor{Kind: actor.KindUser, ID: "usr_1"}))
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		fmt.Println(version, rec.Code)
	}
}
Output:
2.3.9 426
2.4.0 200

type Router

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

A Router registers a module's routes under a path prefix with shared options. Mount passes each module's Routes a Router for that module.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name: "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			gorbital.Get(r, "/v1/books/{id}", findBook)
		},
	})
	if err != nil {
		panic(err)
	}
	describe(api, http.MethodGet, "/v1/books/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get v1 books by ID" tags=[] secured=true deprecated=false

func (*Router) Group

func (r *Router) Group(prefix string, opts ...RouteOption) *Router

Group returns a Router for the routes under prefix, which is empty or starts with a slash, with opts added to the options of r.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/danielgtaylor/huma/v2"

	"gorbital.dev/gorbital"
	"gorbital.dev/gorbital/guard"
	"gorbital.dev/httpx"
	"gorbital.dev/modules/openapi"
)

// The examples build an API as a v0.1 app's routes.go does: Huma on a
// ServeMux with the bearer scheme, and problem+json errors from a mapper.
func newAPI() (*http.ServeMux, huma.API, *httpx.Mapper) {
	mux := http.NewServeMux()
	api := openapi.New(mux, "Shelfie", "1.0.0", openapi.WithBearerAuth("Session token"))
	mapper, err := httpx.NewMapper(slog.New(slog.DiscardHandler))
	if err != nil {
		panic(err)
	}
	openapi.InstallErrors(mapper)
	return mux, api, mapper
}

// describe prints what the OpenAPI document says about an operation.
func describe(api huma.API, method, path string) {
	item := api.OpenAPI().Paths[path]
	op := map[string]*huma.Operation{
		http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put,
		http.MethodPatch: item.Patch, http.MethodDelete: item.Delete,
	}[method]
	fmt.Printf("%s %s id=%s summary=%q tags=%v secured=%t deprecated=%t\n",
		method, path, op.OperationID, op.Summary, op.Tags, len(op.Security) > 0, op.Deprecated)
}

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type bookIDInput struct {
	ID string `path:"id"`
}

type bookBody struct{ Body Book }

var ErrBookNotFound = errors.New("books: book not found")

func findBook(_ context.Context, in *bookIDInput) (*bookBody, error) {
	if in.ID != "bok_1" {
		return nil, ErrBookNotFound
	}
	return &bookBody{Body: Book{ID: in.ID, Title: "Dune"}}, nil
}

func main() {
	_, api, mapper := newAPI()
	err := gorbital.Mount(api, mapper, gorbital.Deps{}, gorbital.Module{
		Name: "books",
		Routes: func(r *gorbital.Router, d gorbital.Deps) {
			v1 := r.Group("/v1", gorbital.Tags("Books"))
			books := v1.Group("/books")
			gorbital.Get(books, "/{id}", findBook)
			gorbital.Get(v1.Group("/catalog", guard.Public()), "/{id}", findBook)
		},
	})
	if err != nil {
		panic(err)
	}
	describe(api, http.MethodGet, "/v1/books/{id}")
	describe(api, http.MethodGet, "/v1/catalog/{id}")
}
Output:
GET /v1/books/{id} id=books-get-v1-books-by-id summary="Get v1 books by ID" tags=[Books] secured=true deprecated=false
GET /v1/catalog/{id} id=books-get-v1-catalog-by-id summary="Get v1 catalog by ID" tags=[Books] secured=false deprecated=false

type Scope added in v0.3.0

type Scope struct {
	// Name is the concept in lowercase singular, such as "organisation" or
	// "merchant". It names guards in logs and metrics and appears in the
	// registration errors the guards return.
	Name string

	// PathParam is the path parameter scope routes carry the ID in, such
	// as "orgId". Registration fails for a scope route whose path lacks
	// it. Empty uses [DefaultScopePathParam].
	PathParam string

	// NotFoundCode is the problem code that refuses a request for a scope
	// the actor is not a member of, such as "org_not_found". Unknown,
	// deleted, forbidden and malformed IDs are all refused with it and
	// 404, so scope IDs can't be probed. Empty uses
	// [DefaultScopeNotFoundCode].
	NotFoundCode string

	// ValidID reports whether s is a well-formed scope ID. A malformed ID
	// is refused exactly as an unknown one is, before the authorizer is
	// asked, so it never reaches the app's queries. Nil accepts any
	// non-empty string, which is correct for an app whose authorizer
	// validates the ID itself.
	ValidID func(s string) bool

	// Roles are the scope's roles, most privileged first, with what each
	// one grants. [Permission.ScopeRoles] names them. Roles a permission
	// names and this list doesn't are declared with a generic description.
	//
	// They order and describe the roles in the scope catalog, which is
	// built before any module's Platform runs. A scope set with
	// [WithScope] is in place by then; one set with [Platform.SetScope] is
	// not, so its roles keep the owner, admin and member order and
	// descriptions of organisation apps. Set the scope with [WithScope]
	// when the roles are the app's own.
	Roles []ScopeRole

	// Session carries the scope into the request's database connections,
	// for row-level security (postgres.WithScope, ADR-0061). Nil leaves
	// connections unscoped, which is correct only for an app that filters
	// by scope in every query.
	Session func(ctx context.Context, scopeID string) context.Context
}

A Scope is the app's tenancy: what a tenant is called, how its IDs look, which roles it has, and how a request's tenant reaches the database (ADR-0088). The framework never learns the app's table names and never queries them; membership lives behind the ScopeAuthorizer.

gorbital.dev/gorbital/orgshttp supplies one for organisations. An app with its own membership tables supplies its own:

gorbital.WithScope(gorbital.Scope{
	Name:         "merchant",
	PathParam:    "merchantId",
	NotFoundCode: "merchant_not_found",
	ValidID:      merchants.ValidID,
	Roles:        merchants.Roles,
	Session:      postgres.WithScope,
}, merchants.NewAuthorizer(db))
Example
package main

import (
	"fmt"
	"strings"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/postgres"
)

// merchantScope is the tenancy of an app whose customers are merchants:
// its own word, its own IDs, its own roles.
func merchantScope() gorbital.Scope {
	return gorbital.Scope{
		Name:         "merchant",
		PathParam:    "merchantId",
		NotFoundCode: "merchant_not_found",
		ValidID:      func(id string) bool { return strings.HasPrefix(id, "mch_") },
		Roles: []gorbital.ScopeRole{
			{Name: "owner", Description: "Runs the merchant"},
			{Name: "manager", Description: "Manages staff and orders"},
			{Name: "courier", Description: "Delivers orders"},
		},
		Session: postgres.WithScope,
	}
}

func main() {
	s := merchantScope()
	fmt.Println(s.Name, s.PathParam, s.NotFoundCode)
	// Routes carry the ID in the scope's own parameter.
	fmt.Println("/v1/merchants/{" + s.PathParam + "}/orders")
}
Output:
merchant merchantId merchant_not_found
/v1/merchants/{merchantId}/orders

func DefaultOrgScope added in v0.3.0

func DefaultOrgScope() Scope

DefaultOrgScope is the tenancy of v0.1 and v0.2 organisation apps: the concept named "organisation" in {orgId}, refused with org_not_found, the roles owner, admin and member, and the organisation carried into the request's connections for row-level security.

It has no ValidID: gorbital does not know how an app's organisation IDs are shaped. gorbital.dev/gorbital/orgshttp supplies orgs.ParseID with its own DefaultScope.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	s := gorbital.DefaultOrgScope()
	fmt.Println(s.Name, s.PathParam, s.NotFoundCode)
	for _, r := range s.Roles {
		fmt.Print(r.Name, " ")
	}
	fmt.Println()
}
Output:
organisation orgId org_not_found
owner admin member

type ScopeAuthorizer added in v0.3.0

type ScopeAuthorizer interface {
	AuthorizeScope(ctx context.Context, scopeID, permission string) (context.Context, error)
}

A ScopeAuthorizer decides whether the actor of a request may act in a scope, for guard.Scope. An app sets one with WithScope; a module sets one with Platform.SetScope.

AuthorizeScope checks that the actor in ctx is a member of scopeID whose role grants permission, and returns a context whose actor acts in the scope: actor.Actor.OrgID set to scopeID, and Permissions those of the role, limited by an API key's scopes. It returns

  • ErrScopeNotFound when scopeID isn't a scope the actor is a member of, deleted and nonexistent ones included;
  • actor.ErrUnauthenticated without a member actor;
  • actor.ErrStepUpRequired when the role grants permission only to sessions verified with a second factor;
  • actor.ErrForbidden when the role doesn't grant it;

and any other error for a failure, which the guard answers with 500. orgs.RequireMember has exactly these semantics.

Example
package main

import (
	"context"
	"fmt"

	"gorbital.dev/actor"
	"gorbital.dev/gorbital"
)

// members is a scope authorizer over the app's own membership table.
type members map[string]string

func (m members) AuthorizeScope(ctx context.Context, scopeID, permission string) (context.Context, error) {
	a, ok := actor.From(ctx)
	if !ok {
		return ctx, actor.ErrUnauthenticated
	}
	role, member := m[scopeID+"/"+a.ID]
	if !member {
		return ctx, gorbital.ErrScopeNotFound
	}
	if role != "owner" && permission == "orders.order.refund" {
		return ctx, actor.ErrForbidden
	}
	a.OrgID, a.Permissions = scopeID, []string{permission}
	return actor.With(ctx, a), nil
}

func main() {
	var authorizer gorbital.ScopeAuthorizer = members{
		"mch_1/usr_ada": "owner",
		"mch_1/usr_bo":  "courier",
	}
	ada := actor.With(context.Background(), actor.Actor{Kind: actor.KindUser, ID: "usr_ada"})
	bo := actor.With(context.Background(), actor.Actor{Kind: actor.KindUser, ID: "usr_bo"})

	ctx, err := authorizer.AuthorizeScope(ada, "mch_1", "orders.order.refund")
	a, _ := actor.From(ctx)
	fmt.Println(a.OrgID, err)

	_, err = authorizer.AuthorizeScope(bo, "mch_1", "orders.order.refund")
	fmt.Println(err)

	_, err = authorizer.AuthorizeScope(ada, "mch_2", "orders.order.read")
	fmt.Println(err)
}
Output:
mch_1 <nil>
actor: permission denied
gorbital: scope not found

type ScopeRole added in v0.3.0

type ScopeRole struct {
	Name        string
	Description string
}

A ScopeRole is one role of a Scope, such as an organisation's owner.

Example
package main

import (
	"fmt"
	"strings"

	"gorbital.dev/gorbital"
	"gorbital.dev/modules/postgres"
)

// merchantScope is the tenancy of an app whose customers are merchants:
// its own word, its own IDs, its own roles.
func merchantScope() gorbital.Scope {
	return gorbital.Scope{
		Name:         "merchant",
		PathParam:    "merchantId",
		NotFoundCode: "merchant_not_found",
		ValidID:      func(id string) bool { return strings.HasPrefix(id, "mch_") },
		Roles: []gorbital.ScopeRole{
			{Name: "owner", Description: "Runs the merchant"},
			{Name: "manager", Description: "Manages staff and orders"},
			{Name: "courier", Description: "Delivers orders"},
		},
		Session: postgres.WithScope,
	}
}

func main() {
	for _, r := range merchantScope().Roles {
		fmt.Printf("%s: %s\n", r.Name, r.Description)
	}
}
Output:
owner: Runs the merchant
manager: Manages staff and orders
courier: Delivers orders

type SignInMethod

type SignInMethod struct {
	// Key identifies the method, such as "passkeys".
	Key string
	// Name is how people call it, such as "Passkeys in browsers".
	Name    string
	Enabled bool
	// Detail describes an enabled method, such as its relying party ID;
	// never a secret.
	Detail string
	// Missing are the environment variables that turn a disabled method on.
	Missing []string
	// Guide is the documentation section that explains the method, such as
	// "AUTH_PROVIDERS.md#passkeys".
	Guide string
}

A SignInMethod is a way to sign in and whether the app has it configured, as GET /ops/auth/providers lists it and the auth-providers command prints it (ADR-0045). It never holds configuration values.

Example

An authenticator reports its sign-in methods with a SignInMethods method, which Platform.SignInMethods calls with the app's configuration.

package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	methods := func(cfg gorbital.Config) []gorbital.SignInMethod {
		if cfg.Auth.GitHubClientID != "" {
			return []gorbital.SignInMethod{{Key: "github", Name: "GitHub sign-in", Enabled: true, Detail: "client " + cfg.Auth.GitHubClientID}}
		}
		return []gorbital.SignInMethod{{
			Key: "github", Name: "GitHub sign-in",
			Missing: []string{"GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"}, Guide: "AUTH_PROVIDERS.md#github-sign-in",
		}}
	}
	for _, m := range methods(gorbital.Config{}) {
		fmt.Println(m.Key, m.Enabled, m.Missing)
	}
}
Output:
github false [GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]

type Stack

type Stack struct {
	// Recover turns a panic into a 500 problem response (httpx.Recover).
	Recover func(http.Handler) http.Handler
	// TrustedProxies sets the client's address from X-Forwarded-For sent
	// by APP_TRUSTED_PROXIES (httpx.TrustedProxies, ADR-0052).
	TrustedProxies func(http.Handler) http.Handler
	// RequestID gives every request an ID, accepting X-Request-ID only
	// from APP_TRUSTED_CALLERS (httpx.RequestIDFrom).
	RequestID func(http.Handler) http.Handler
	// Telemetry records a span and metrics per request.
	Telemetry func(http.Handler) http.Handler
	// Observability counts requests per route for /ops/observability and
	// automatic incidents (ADR-0064).
	Observability func(http.Handler) http.Handler
	// AccessLog logs one structured line per request (httpx.AccessLog).
	AccessLog func(http.Handler) http.Handler
	// Timeout answers 503 request_timeout when a handler hasn't started its
	// response within APP_REQUEST_TIMEOUT, and cancels the request's
	// context (gorbital.dev/httpx/timeout, ADR-0085). A route can shorten it with
	// [Timeout].
	Timeout func(http.Handler) http.Handler
	// SecureHeaders sets security headers, and HSTS in production
	// (httpx.SecureHeaders).
	SecureHeaders func(http.Handler) http.Handler
	// CORS answers browsers on APP_CORS_ORIGINS (httpx.CORS).
	CORS func(http.Handler) http.Handler
	// CrossOrigin refuses cross-site writes that browsers send with
	// cookies (httpx.CrossOrigin), except the sign-in callbacks other sites
	// post to by design.
	CrossOrigin func(http.Handler) http.Handler
	// BodyLimit refuses bodies over APP_MAX_BODY_BYTES (httpx.BodyLimit).
	BodyLimit func(http.Handler) http.Handler
	// Maintenance answers 503 while the maintenance.enabled setting is on,
	// except health checks, docs, sign-in and /ops (httpx.Maintenance).
	Maintenance func(http.Handler) http.Handler
	// Auth runs the authenticator's middleware ([WithAuth]); without an
	// authenticator it passes requests on unchanged.
	Auth func(http.Handler) http.Handler
	// RateLimit limits requests to /v1/auth/ per client address, by the
	// auth.ip_requests_per_minute setting, shared by every instance.
	RateLimit func(http.Handler) http.Handler
	// Idempotency replays a signed-in POST or PATCH retried with the same
	// Idempotency-Key (ADR-0060).
	Idempotency func(http.Handler) http.Handler
}

Stack is the built-in middleware of an app, one field per step, which New builds from the configuration (ADR-0083). The fields are in the default order, outermost first; Stack.Default returns them in that order. Change the order, leave steps out or add your own between them with WithStack:

gorbital.WithStack(func(s gorbital.Stack) []func(http.Handler) http.Handler {
	return []func(http.Handler) http.Handler{
		s.Recover, s.TrustedProxies, s.RequestID, requireTenantHeader, // yours, early
		s.Telemetry, s.Observability, s.AccessLog, s.Timeout, s.SecureHeaders, s.CORS,
		s.CrossOrigin, s.BodyLimit, s.Maintenance, s.Auth, s.RateLimit, s.Idempotency,
	}
})
Example
package main

import (
	"fmt"
	"net/http"

	"gorbital.dev/gorbital"
)

func main() {
	name := func(label string) func(http.Handler) http.Handler {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				fmt.Print(label, " ")
				next.ServeHTTP(w, r)
			})
		}
	}
	s := gorbital.Stack{Recover: name("recover"), RequestID: name("request-id"), Auth: name("auth")}
	var h http.Handler = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fmt.Println("handler") })
	for _, mw := range []func(http.Handler) http.Handler{s.Auth, s.RequestID, s.Recover} {
		h = mw(h)
	}
	h.ServeHTTP(nil, nil)
}
Output:
recover request-id auth handler

func (Stack) Default

func (s Stack) Default() []func(http.Handler) http.Handler

Default returns the steps in the default order, outermost first: the order of a v0.1 app's routes.go, with Timeout added after AccessLog so timed-out requests are logged with their 503.

Example
package main

import (
	"fmt"

	"gorbital.dev/gorbital"
)

func main() {
	var s gorbital.Stack
	fmt.Println(len(s.Default()))
}
Output:
15

type StorageConfig

type StorageConfig struct {
	Driver    string // STORAGE_DRIVER: local (default), s3, spaces, r2 or minio
	LocalDir  string // STORAGE_LOCAL_DIR, default .orb/storage
	Endpoint  string // STORAGE_ENDPOINT; defaulted from the region for s3 and spaces
	Region    string // STORAGE_REGION
	Bucket    string // STORAGE_BUCKET
	AccessKey string // STORAGE_ACCESS_KEY
	SecretKey config.Secret
	PublicURL string // STORAGE_PUBLIC_URL
	// PathStyle addresses buckets by path (STORAGE_PATH_STYLE, default on
	// for minio).
	PathStyle bool
	// SigningKey signs local signed URLs (STORAGE_SIGNING_KEY); random per
	// start when empty, so those URLs stop working at a restart.
	SigningKey config.Secret
}

StorageConfig is file storage from STORAGE_* (ADR-0075). The local driver is built in; S3-compatible drivers are passed by the app (WithStorage), built from these values.

Example
package main

import (
	"fmt"

	"gorbital.dev/config"
	"gorbital.dev/gorbital"
)

// developmentEnv is a configuration source for the examples.
func developmentEnv(vars map[string]string) config.Source {
	return config.Source{Getenv: func(k string) string { return vars[k] }}
}

func main() {
	cfg, err := gorbital.LoadConfig(developmentEnv(map[string]string{
		"APP_ENV": "development", "STORAGE_DRIVER": "s3", "STORAGE_REGION": "eu-west-1",
		"STORAGE_BUCKET": "files", "STORAGE_ACCESS_KEY": "AKIA", "STORAGE_SECRET_KEY": "secret",
	}))
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Storage.Driver, cfg.Storage.Endpoint, cfg.Storage.Bucket, cfg.Storage.SecretKey)
}
Output:
s3 s3.eu-west-1.amazonaws.com files [redacted]

Directories

Path Synopsis
Package authhttp is sign-in for apps on gorbital.Main: registration with email verification, passwords, sessions in a cookie or a bearer token, two-factor authentication with authenticator apps and passkeys, Google, Apple and GitHub sign-in, API keys and service accounts, platform roles, and the operators' account APIs under /ops/auth/users (ADR-0024, ADR-0038, ADR-0043 to ADR-0046, ADR-0058, ADR-0059, ADR-0070).
Package authhttp is sign-in for apps on gorbital.Main: registration with email verification, passwords, sessions in a cookie or a bearer token, two-factor authentication with authenticator apps and passkeys, Google, Apple and GitHub sign-in, API keys and service accounts, platform roles, and the operators' account APIs under /ops/auth/users (ADR-0024, ADR-0038, ADR-0043 to ADR-0046, ADR-0058, ADR-0059, ADR-0070).
internal/delivery
Package delivery is the auth module's HTTP adapter: Huma operations and request and response types for /v1/auth.
Package delivery is the auth module's HTTP adapter: Huma operations and request and response types for /v1/auth.
internal/delivery/jobs/authcleanup
Package authcleanup runs the auth_cleanup background job: it removes ended sessions, old email codes, accounts deleted longer ago than the auth.deleted_account_retention runtime setting, accounts still unverified after auth.unverified_account_ttl, and API keys expired or revoked more than 30 days ago, recording each key's expiry first.
Package authcleanup runs the auth_cleanup background job: it removes ended sessions, old email codes, accounts deleted longer ago than the auth.deleted_account_retention runtime setting, accounts still unverified after auth.unverified_account_ttl, and API keys expired or revoked more than 30 days ago, recording each key's expiry first.
internal/delivery/jobs/authrevoke
Package authrevoke runs the auth_revoke_tokens background job: it revokes the Google and Apple refresh tokens queued when an identity is unlinked or an account deleted, retrying failures with backoff (ADR-0046).
Package authrevoke runs the auth_revoke_tokens background job: it revokes the Google and Apple refresh tokens queued when an identity is unlinked or an account deleted, retrying failures with backoff (ADR-0046).
internal/delivery/signintest
Package signintest checks an app's sign-in configuration from the Dev Portal (ADR-0087): offline checks of each method's variables, network checks against Google, Apple and GitHub, and live tests that go through the real provider, a passkey ceremony, an authenticator app code or an email, without creating accounts, identity links, sessions, audit events or database rows.
Package signintest checks an app's sign-in configuration from the Dev Portal (ADR-0087): offline checks of each method's variables, network checks against Google, Apple and GitHub, and live tests that go through the real provider, a passkey ceremony, an authenticator app code or an email, without creating accounts, identity links, sessions, audit events or database rows.
internal/domain
Package domain holds the auth module's rules that don't depend on storage or HTTP.
Package domain holds the auth module's rules that don't depend on storage or HTTP.
internal/repository
Package repository stores the auth module's accounts, sessions, codes and role assignments in PostgreSQL with hand-written SQL, one file per operation (ADR-0032).
Package repository stores the auth module's accounts, sessions, codes and role assignments in PostgreSQL with hand-written SQL, one file per operation (ADR-0032).
internal/repository/migrations
Package migrations embeds sign-in's goose migrations, numbered locally.
Package migrations embeds sign-in's goose migrations, numbered locally.
internal/usecase
Package usecase holds the auth module's application logic: registration, email verification, sign-in, sessions, password reset and change, account deletion and platform roles.
Package usecase holds the auth module's application logic: registration, email verification, sign-in, sessions, password reset and change, account deletion and platform roles.
Package flagshttp is the client feature flags API as a gorbital module: GET /v1/flags tells a signed-in caller whether each flag declared with flags.Client() is on for them (ADR-0057).
Package flagshttp is the client feature flags API as a gorbital module: GET /v1/flags tells a signed-in caller whether each flag declared with flags.Client() is on for them (ADR-0057).
internal/delivery
Package delivery is the HTTP adapter of the feature flags module.
Package delivery is the HTTP adapter of the feature flags module.
internal/domain
Package domain holds the feature flags module's errors.
Package domain holds the feature flags module's errors.
internal/usecase
Package usecase holds the feature flags module's application logic.
Package usecase holds the feature flags module's application logic.
Package gorbitaltest tests a gorbital app through its real middleware stack, on its own PostgreSQL database per test (ADR-0028):
Package gorbitaltest tests a gorbital app through its real middleware stack, on its own PostgreSQL database per test (ADR-0028):
Package guard provides route options that decide whether a request may reach a route's handler (ADR-0082).
Package guard provides route options that decide whether a request may reach a route's handler (ADR-0082).
internal
builtinjobs
Package builtinjobs holds the background jobs gorbital.New defines for what it builds: expired rate-limit buckets and idempotency keys, old request minutes, automatic incidents and data retention.
Package builtinjobs holds the background jobs gorbital.New defines for what it builds: expired rate-limit buckets and idempotency keys, old request minutes, automatic incidents and data retention.
route
Package route holds the route configuration that the gorbital and guard packages both set, so guards can be route options without the gorbital package exporting its internals.
Package route holds the route configuration that the gorbital and guard packages both set, so guards can be route options without the gorbital package exporting its internals.
Package mailevents is the mail events module: POST /v1/webhooks/resend receives Resend's signed bounce and complaint webhooks and puts the addresses that bounced permanently or complained on the suppression list, which the mail worker checks before every send (ADR-0062).
Package mailevents is the mail events module: POST /v1/webhooks/resend receives Resend's signed bounce and complaint webhooks and puts the addresses that bounced permanently or complained on the suppression list, which the mail worker checks before every send (ADR-0062).
internal/delivery
Package delivery is the HTTP adapter of the mail events module.
Package delivery is the HTTP adapter of the mail events module.
internal/domain
Package domain holds the mail events module's events, rules and errors.
Package domain holds the mail events module's events, rules and errors.
internal/usecase
Package usecase holds the mail events module's application logic: it verifies the email provider's webhooks and puts the recipients of hard bounces and complaints on the suppression list (ADR-0062).
Package usecase holds the mail events module's application logic: it verifies the email provider's webhooks and puts the recipients of hard bounces and complaints on the suppression list (ADR-0062).
Package operation registers operations declared as huma.Operation values, as v0.1 apps declare them for huma.Register, on a gorbital.Router.
Package operation registers operations declared as huma.Operation values, as v0.1 apps declare them for huma.Register, on a gorbital.Router.
Package opshttp is the operations API as a gorbital module: the admin endpoints under /ops/ for runtime settings, feature flags, background jobs, the audit log, releases, email and its suppression list, file storage, sign-in methods and rate limits, system health, retention, live observability and incidents (ADR-0026, ADR-0051, ADR-0064).
Package opshttp is the operations API as a gorbital module: the admin endpoints under /ops/ for runtime settings, feature flags, background jobs, the audit log, releases, email and its suppression list, file storage, sign-in methods and rate limits, system health, retention, live observability and incidents (ADR-0026, ADR-0051, ADR-0064).
internal/delivery
Package delivery is the HTTP adapter of the operations module.
Package delivery is the HTTP adapter of the operations module.
internal/domain
Package domain holds the operations module's permissions and errors.
Package domain holds the operations module's permissions and errors.
internal/usecase
Package usecase holds the operations module's application logic: every operation checks the actor's permission, then calls the settings store, jobs manager, audit log, release log or mailer.
Package usecase holds the operations module's application logic: every operation checks the actor's permission, then calls the settings store, jobs manager, audit log, release log or mailer.
Package orgshttp is organisations as a gorbital module, for multi-tenant apps (ADR-0023, ADR-0048): organisations with members holding one role each (owner, admin, member, and the roles the app's modules grant), invitations by email, a personal workspace per account, deletion with a restore period and the orgs_purge job, each organisation's own runtime settings (ADR-0056) and client feature flags (ADR-0057), and organisations' service accounts with their API keys (ADR-0058).
Package orgshttp is organisations as a gorbital module, for multi-tenant apps (ADR-0023, ADR-0048): organisations with members holding one role each (owner, admin, member, and the roles the app's modules grant), invitations by email, a personal workspace per account, deletion with a restore period and the orgs_purge job, each organisation's own runtime settings (ADR-0056) and client feature flags (ADR-0057), and organisations' service accounts with their API keys (ADR-0058).
internal/delivery
Package delivery is the orgs module's HTTP adapter: Huma operations and request and response types for /v1/orgs and /v1/invitations.
Package delivery is the orgs module's HTTP adapter: Huma operations and request and response types for /v1/orgs and /v1/invitations.
internal/delivery/jobs/orgspurge
Package orgspurge runs the orgs_purge background job: it removes organisations deleted longer ago than the orgs.deleted_org_retention runtime setting, with every org-scoped row.
Package orgspurge runs the orgs_purge background job: it removes organisations deleted longer ago than the orgs.deleted_org_retention runtime setting, with every org-scoped row.
internal/domain
Package domain holds the orgs module's entities and rules: organisations, members and invitations (ADR-0048).
Package domain holds the orgs module's entities and rules: organisations, members and invitations (ADR-0048).
internal/repository
Package repository stores the orgs module's organisations, members and invitations in PostgreSQL with hand-written SQL, one file per operation (ADR-0032).
Package repository stores the orgs module's organisations, members and invitations in PostgreSQL with hand-written SQL, one file per operation (ADR-0032).
internal/repository/migrations
Package migrations embeds the organisations module's goose migrations, numbered locally.
Package migrations embeds the organisations module's goose migrations, numbered locally.
internal/usecase
Package usecase holds the orgs module's application logic: organisations, members, invitations, personal workspaces, deletion and purging (ADR-0048).
Package usecase holds the orgs module's application logic: organisations, members, invitations, personal workspaces, deletion and purging (ADR-0048).

Jump to

Keyboard shortcuts

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