Help us shape the future of Go! Take the 2026 Go Developer Survey

api

package
v0.30.0 Latest Latest
Warning

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

Go to latest
Published: Oct 19, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package api holds REST helpers on top of web and orm: resource transformers, pagination envelopes, allowlisted query conventions (filter, sort, fields, include, pagination) and deprecation headers.

Register adds typed handlers (input bound and validated, output written as JSON) that package openapi describes; Collection, Page and Cursor answer list endpoints in an Envelope; Query applies the allowlisted query string to an orm.Query; Idempotency and Deprecate are middleware. API-only apps start from `vukem new <name> --api`.

See https://github.com/AlexGalhardo/vukem/blob/main/docs/api.md.

Index

Examples

Constants

View Source
const (
	DefaultMaxPage         = 1000
	DefaultMaxFilterValues = 100
)

Default bounds of Query; see MaxPage and MaxFilterValues.

View Source
const IdempotencyHeader = "Idempotency-Key"

IdempotencyHeader is the request header carrying the key.

Variables

This section is empty.

Functions

func Collection

func Collection[T, R any](c *web.Ctx, items []T, fn func(T) R) error

Collection answers 200 with {"data": [...]}.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/AlexGalhardo/vukem/api"
	"github.com/AlexGalhardo/vukem/router"
	"github.com/AlexGalhardo/vukem/web"
)

type exProductJSON struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func main() {
	r := router.New(nil)
	r.Get("/products", func(c *web.Ctx) error {
		names := []string{"Mug", "Cap"}
		return api.Collection(c, names, func(n string) exProductJSON { return exProductJSON{Name: n} })
	})

	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/products", http.NoBody))

	fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
}
Output:
200 {"data":[{"id":0,"name":"Mug"},{"id":0,"name":"Cap"}]}

func Cursor

func Cursor[T, R any](c *web.Ctx, p orm.CursorPage[T], fn func(T) R) error

Cursor answers 200 with a keyset page: meta.next_cursor and links.next (null on the last page).

func Deprecate

func Deprecate(since, sunset time.Time, link string) func(http.Handler) http.Handler

Deprecate marks routes as deprecated (RFC 9745 Deprecation and RFC 8594 Sunset headers) and links to the replacement or migration guide. Apply it to a version group: g.Use(api.Deprecate(since, sunset, "/docs/v2")).

func Idempotency added in v0.15.0

func Idempotency(store cache.Store, o IdempotencyOptions) func(http.Handler) http.Handler

Idempotency makes retried requests safe: the first request with an Idempotency-Key runs and its response (status below 500) is stored; repeats replay it with "Idempotent-Replayed: true". A repeat while the first is running gets 409, and reusing a key for a different request gets 422. If the store fails the request is refused with 503 (fail closed) rather than risking a duplicate side effect.

func Map

func Map[T, R any](items []T, fn func(T) R) []R

Map transforms every item with fn; resources use it to turn models into their wire format: func Products(ps []models.Product) []ProductJSON { return api.Map(ps, Product) }.

func Page

func Page[T, R any](c *web.Ctx, p orm.Page[T], fn func(T) R) error

Page answers 200 with an offset page: data, meta (page, per_page, total, last_page) and links (self, next, prev).

func Register added in v0.15.0

func Register[In, Out any](g Registrar, pattern string, op Op, fn func(*web.Ctx, In) (Out, error)) *router.Route

Register adds a typed handler. The input is built from the query string, the JSON or form body and the path parameters (which win), and validated with In's `validate` tags (422 with field errors). The output is written as JSON, checked first when Op.ValidateResponse is set. GET, HEAD and DELETE ignore the body. Pattern is "METHOD /path". It panics on a malformed pattern or when Op.ValidateResponse is set for a non-struct Out, so mistakes surface at boot.

api.Register(g, "POST /products", api.Op{Name: "products.store"},
	func(c *web.Ctx, in StoreProduct) (ProductJSON, error) { ... })

Use RegisterWith when the input needs a rule `validate` tags cannot express.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/AlexGalhardo/vukem/api"
	"github.com/AlexGalhardo/vukem/router"
	"github.com/AlexGalhardo/vukem/web"
)

type exStoreProduct struct {
	Name  string `json:"name"  validate:"required"`
	Price int    `json:"price" validate:"min=1"`
}

type exProductJSON struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func main() {
	r := router.New(nil)
	api.Register(r, "POST /products", api.Op{Name: "products.store", Tags: []string{"products"}},
		func(_ *web.Ctx, in exStoreProduct) (exProductJSON, error) {
			return exProductJSON{ID: 1, Name: in.Name}, nil
		})

	req := httptest.NewRequest(http.MethodPost, "/products", strings.NewReader(`{"name":"Mug","price":12}`))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, req)

	fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
}
Output:
201 {"id":1,"name":"Mug"}

func RegisterWith added in v0.29.0

func RegisterWith[In, Out any](
	g Registrar,
	pattern string,
	op Op,
	schema z.Schema[In],
	fn func(*web.Ctx, In) (Out, error),
) *router.Route

RegisterWith adds a typed handler that validates its input with schema instead of In's `validate` tags, for the rules tags cannot express: a bound computed from a value (z.Slice(z.String()).Max(len(allowed))) or a cross-field z.Refine. Everything else is Register: the same binding order (query string, body, path parameters, which win), the same 422 with field errors, the same output handling. It panics on a nil schema, so the mistake surfaces at boot.

The OpenAPI document is generated from In and Out (their `json`, `validate` and `doc` tags), so schema never reaches it: the document describes exactly what Register would have documented. Keep the static rules in the tags and let schema add only what they cannot say, or the document under-describes what the route enforces.

schema := z.Struct[CreateKey](z.Shape{
	"name":      z.String().Min(1).Max(20),
	"abilities": z.Slice(z.String()).Min(1).Max(len(abilities.All())),
}).Refine(checkKey)
api.RegisterWith(g, "POST /keys", api.Op{Name: "keys.store"}, schema,
	func(c *web.Ctx, in CreateKey) (KeyJSON, error) { ... })
Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/AlexGalhardo/vukem/api"
	"github.com/AlexGalhardo/vukem/router"
	"github.com/AlexGalhardo/vukem/web"
	"github.com/AlexGalhardo/vukem/z"
)

type exSubscribe struct {
	Plans []string `json:"plans"`
}

type exProductJSON struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func main() {
	// The allowlist is a runtime value, so `validate:"max=..."` cannot say it.
	plans := []string{"free", "pro"}
	schema := z.Struct[exSubscribe](z.Shape{
		"plans": z.Slice(z.String().OneOf(plans...)).Min(1).Max(len(plans)),
	}).Refine(func(s exSubscribe) error {
		if len(s.Plans) == 2 && s.Plans[0] == s.Plans[1] {
			return z.FieldError("plans", "plans must differ")
		}

		return nil
	})

	r := router.New(nil)
	api.RegisterWith(r, "POST /subscriptions", api.Op{Name: "subscriptions.store"}, schema,
		func(_ *web.Ctx, in exSubscribe) (exProductJSON, error) {
			return exProductJSON{ID: 1, Name: strings.Join(in.Plans, ",")}, nil
		})

	req := httptest.NewRequest(http.MethodPost, "/subscriptions", strings.NewReader(`{"plans":["free","pro"]}`))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, req)

	fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
}
Output:
201 {"id":1,"name":"free,pro"}

Types

type Envelope

type Envelope[R any] struct {
	Data  []R    `json:"data"`
	Meta  *Meta  `json:"meta,omitempty"`
	Links *Links `json:"links,omitempty"`
}

Envelope is the JSON body of collections and pages.

type IdempotencyOptions added in v0.15.0

type IdempotencyOptions struct {
	// Methods guarded (default POST and PATCH).
	Methods []string
	// Required answers 400 when the header is missing.
	Required bool
	// TTL keeps completed responses (default 24h).
	TTL time.Duration
	// LockTTL bounds an in-flight request (default 1 minute).
	LockTTL time.Duration
	// MaxBody bounds the body of a keyed request (default 1 MiB). The body
	// is read whole to fingerprint it, so a larger one is refused with 413
	// before the handler runs, whatever a route-level body limit allows.
	// Raise it for keyed routes that accept bigger bodies; requests without
	// a key are not affected.
	MaxBody int64
	// ReplayHeaders lists the response headers stored and replayed besides
	// Content-Type. Default: the headers describing the stored
	// representation (Content-Language, Content-Disposition,
	// Content-Location, Location, ETag, Last-Modified, Link); a non-nil list
	// replaces it. Set-Cookie, Authorization, Proxy-Authorization,
	// Proxy-Authenticate and WWW-Authenticate are never replayed, even when
	// listed. Every other header is written fresh by the middleware of the
	// repeat request (X-Request-ID, RateLimit-*, security headers) or
	// dropped, so a replay never carries the first request's per-request
	// values or credentials.
	ReplayHeaders []string
	// Scope separates callers so one cannot replay another's response.
	// Default: the authenticated principal (auth.Principal: guard and user
	// id) when an auth middleware ran before Idempotency, else a SHA-256 of
	// the Authorization header. Place Idempotency after auth.Required so
	// a revoked token is refused before any replay. A request with a key
	// whose scope is empty is refused with 400; return a constant to share
	// keys on purpose.
	Scope func(r *http.Request) string
}

IdempotencyOptions tune Idempotency.

type Links struct {
	Self string  `json:"self"`
	Next *string `json:"next"`
	Prev *string `json:"prev"`
}

Links point to neighbour pages; nil encodes as null.

type Meta

type Meta struct {
	Page       int    `json:"page,omitempty"`
	PerPage    int    `json:"per_page"`
	Total      *int64 `json:"total,omitempty"`
	LastPage   int    `json:"last_page,omitempty"`
	NextCursor string `json:"next_cursor,omitempty"`
}

Meta describes a page.

type Op added in v0.15.0

type Op struct {
	// Name is the route name (and the OpenAPI operationId).
	Name        string
	Summary     string
	Description string
	Tags        []string
	// Status is the success status; default 201 for POST, 204 when the
	// output type is struct{}, 200 otherwise.
	Status     int
	Deprecated bool
	// Security lists the security schemes the OpenAPI document declares
	// (e.g. "bearer"). It is documentation only and does not authenticate
	// the route: add auth.Required (and auth.Ability) to the route or group.
	Security []string
	// ValidateResponse checks the output against its `validate` tags
	// (z.For[Out]) before writing it, when web.Services.CheckResponses is
	// on: the app sets it whenever APP_ENV is not production, so a staging
	// deployment with APP_ENV=development or test runs the check too. A
	// mismatch is a server bug: it is logged and answered with a generic 500
	// instead of sending data that breaks the documented contract. Out must
	// be a struct; Register panics otherwise.
	ValidateResponse bool
}

Op describes a typed operation for the router and the OpenAPI document.

type Operation added in v0.15.0

type Operation struct {
	Op
	Method string
	Path   string
	Params []string
	In     reflect.Type
	Out    reflect.Type
}

Operation is attached to every typed route (router.RouteInfo.Doc) so generators know its input and output types.

type Option

type Option func(*spec)

Option allowlists one query convention for an endpoint.

func Fields

func Fields(cols ...string) Option

Fields allows ?fields=col,col (sparse fieldsets).

func Filters

func Filters(cols ...string) Option

Filters allows ?filter[col]=value (comma-separated values mean IN).

func Includes

func Includes(rels ...string) Option

Includes allows ?include=rel,rel. An include naming a relation field of the model (by Go field name, its snake_case form or its json tag, so "author_profile" matches AuthorProfile) is eager-loaded on Request.Query with orm.Query.With; any other allowlisted name only lands in Request.Includes for the handler to act on.

func MaxFilterValues added in v0.24.0

func MaxFilterValues(n int) Option

MaxFilterValues bounds the comma-separated values of one ?filter[col] (default DefaultMaxFilterValues). n <= 0 keeps the default.

func MaxPage added in v0.24.0

func MaxPage(n int) Option

MaxPage bounds ?page (default DefaultMaxPage), so a request cannot make the database skip millions of rows (OFFSET); use ?cursor for deep lists. n <= 0 keeps the default.

func PerPage

func PerPage(def, maxPerPage int) Option

PerPage sets the default and maximum ?per_page (larger values are capped); the maximum also replaces orm.DefaultMaxPerPage for the query.

func Sorts

func Sorts(cols ...string) Option

Sorts allows ?sort=col,-col. The last entry is the default sort; a leading "-" means descending.

type Registrar added in v0.15.0

type Registrar interface {
	Match(methods []string, path string, h web.HandlerFunc) []*router.Route
}

Registrar is a router.Router or router.Group.

type Request

type Request[T any] struct {
	// Query has the filters, sort, fields and relation includes applied.
	Query   orm.Query[T]
	Page    int
	PerPage int
	Cursor  string
	// Includes lists the allowlisted ?include names the client sent, as
	// sent.
	Includes []string
	// contains filtered or unexported fields
}

Request is a query refined by the allowlisted conventions.

func Query

func Query[T any](c *web.Ctx, q orm.Query[T], opts ...Option) (Request[T], error)

Query applies ?filter, ?sort, ?fields, ?include, ?page, ?per_page and ?cursor to q. Every column must be allowlisted: an unknown filter, sort, field or include is a 400 problem naming the allowed values, so request input never chooses columns freely. A ?page above MaxPage or a filter with more than MaxFilterValues values is a 400 too.

func (Request[T]) CursorPaginate added in v0.28.0

func (r Request[T]) CursorPaginate(ctx context.Context, dir orm.Direction) (orm.CursorPage[T], error)

CursorPaginate runs a keyset page of the refined query from r.Cursor, ordered by the primary key in dir. The keyset order replaces the default sort of Sorts; a ?sort the client sent explicitly cannot be honoured with a cursor and is a 400 problem.

func (Request[T]) Paginate

func (r Request[T]) Paginate(ctx context.Context) (orm.Page[T], error)

Paginate runs an offset page of the refined query.

Jump to

Keyboard shortcuts

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