httpx

package
v1.35.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package httpx is the HTTP scaffolding every gogo app shares: the archetype gate that turns a route's Access set into the right allow/deny, the JSON error contract, content negotiation, security headers with a composable CSP, and the principal request-context plumbing. It sits above auth and store; the web package sits above it. See docs/design.md, section 2 "The route contract".

Index

Constants

View Source
const MaxBodyBytes = 1 << 16

MaxBodyBytes bounds an API request body at 64 KiB: enormous for a JSON resource, and small enough that a hostile body costs nothing to refuse. A byte stream -- an upload -- is not a resource and does not come through here. See docs/design.md, FR-10.5.

Variables

View Source
var (
	Error400BadRequest            = huma.Error400BadRequest
	Error401Unauthorized          = huma.Error401Unauthorized
	Error403Forbidden             = huma.Error403Forbidden
	Error404NotFound              = huma.Error404NotFound
	Error409Conflict              = huma.Error409Conflict
	Error413RequestEntityTooLarge = huma.Error413RequestEntityTooLarge
	Error429TooManyRequests       = huma.Error429TooManyRequests
	Error500InternalServerError   = huma.Error500InternalServerError
	Error503ServiceUnavailable    = huma.Error503ServiceUnavailable
)

The refusals a typed handler makes on its own account -- an ownership check, a policy, a field a caller may not set -- as distinct from one its store made, which goes through an Errors table. Re-exported so an app never imports huma to say "no".

View Source
var ErrBadJSON = errors.New("body is not valid JSON")

ErrBadJSON is what a body that is not JSON is refused with. It is the reason string a caller sees, so it says what is wrong with their request and nothing about ours.

Functions

func BaselineOf added in v1.8.0

func BaselineOf(ctx context.Context) *auth.Principal

BaselineOf is the identity that would be left underneath this caller if their session went away: the certificate or trusted-subnet grant a browser presents automatically and cannot sign out of, or anonymous when there is none. The chrome derives its sign-out wording from it, and GET /api/v1/me reports it.

It is lazy -- answering costs a header parse and a database read, and one endpoint asks -- so the gate stores a closure over the request, letting a typed handler ask a question only the request can answer without handing it the request.

func ClientIPOf added in v1.6.0

func ClientIPOf(ctx context.Context) string

ClientIPOf returns the caller's address as the trusted-proxy rules resolved it, or "" if no gate ran. Both gates put it there, from one resolver.

func Decode added in v1.6.0

func Decode[T any](w http.ResponseWriter, r *http.Request) (T, bool)

Decode reads a bounded JSON body into T and, on a malformed one, answers the caller with a 400 through the same negotiation every other refusal uses. It reports whether the handler may go on, so a handler reads as:

body, ok := httpx.Decode[createUserRequest](w, r)
if !ok {
	return
}

func DecodeBody added in v1.6.0

func DecodeBody[T any](r *http.Request) (T, error)

DecodeBody reads a bounded JSON body into T. It answers nobody: handlers that also accept a form post (/login, /reset, /verify-email) render their own refusal, on their own page. It deliberately does not reject unknown fields -- that belongs where the schema owns decoding and can say which field was wrong.

func Fail

func Fail(w http.ResponseWriter, r *http.Request, code int, reason string)

Fail renders an error as JSON for API callers, as an HTML page for a browser (when a renderer is installed), and as plain text for everything else. Handlers call it for their own denials, so their negotiation matches the gate's.

func PrincipalOf

func PrincipalOf(ctx context.Context) *auth.Principal

PrincipalOf returns the principal the gate attached, or the anonymous principal if none (a handler behind Public may see anonymous). It takes a context and not a request, so both halves of the route contract can use it: a typed handler is given a context and no request at all.

func Register added in v1.6.0

func Register[I, O any](a *API, op Op, handler func(context.Context, *I) (*O, error))

Register adds a typed operation. It is a free function rather than a method because Go has no generic methods.

It panics when the operation declares no Access, so a forgotten gate is a bug found at boot rather than in an access log. The gate fails closed anyway -- auth.Access is a bitset whose zero value permits no archetype -- so the panic is a convenience, not the only thing between a forgotten gate and an open door. Say Public if that is what you mean. See docs/design.md, FR-2.4.

func SecurityHeaders

func SecurityHeaders(csp string, next http.Handler) http.Handler

SecurityHeaders applies the standard headers to every response, with the given (already-rendered) Content-Security-Policy.

func SetHTMLError

func SetHTMLError(fn func(w http.ResponseWriter, r *http.Request, code int, reason string))

SetHTMLError installs the HTML error-page renderer. gogo.Run calls it with the web package's renderer.

func WriteJSON

func WriteJSON(w http.ResponseWriter, code int, body any)

WriteJSON emits a success JSON body. Handlers use it for their resources.

Types

type API

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

API is the typed half of the route contract: huma over the router's own mux, and not a second router. humago.New takes a Mux interface *http.ServeMux already satisfies, so a typed operation and a raw route are registered on the same mux, matched by the same matcher, and wrapped by the same logger and security headers.

func NewAPI added in v1.6.0

func NewAPI(mux *http.ServeMux, cfg Config, info APIInfo) *API

NewAPI builds the typed half over an existing mux.

func (*API) Huma added in v1.6.0

func (a *API) Huma() huma.API

Huma exposes the underlying API, for a custom format or security scheme. Registering an operation goes through Register, so that the gate is not optional.

func (*API) Spec added in v1.6.0

func (a *API) Spec() *huma.OpenAPI

Spec returns the generated OpenAPI document: what `make spec-check` diffs against the golden file, and what an app serves itself if it does not want the document at an ungated path.

type APIInfo added in v1.6.0

type APIInfo struct {
	Title   string
	Version string
	// SpecPath serves the OpenAPI document, without an extension: "/gogo/openapi"
	// answers /gogo/openapi.json and /gogo/openapi.yaml. Empty serves neither.
	SpecPath string
	// DocsPath serves the human-readable API browser. Empty serves none.
	DocsPath string
	// SessionCookie is the name of the app's session cookie, so the document can
	// name the credential a browser actually carries. Empty declares only the token.
	SessionCookie string
}

APIInfo names the generated document and says where it is served.

type CSP

type CSP map[string][]string

CSP is a Content-Security-Policy as directive -> sources.

The default is strict, and script-src has no 'unsafe-inline' -- permanently: gogo and its apps serve no inline script, and confirmations go through the shared modal. An app may extend a directive additively; it must not weaken the base.

func DefaultCSP

func DefaultCSP() CSP

DefaultCSP returns the strict base policy shared by every app.

func (CSP) Add

func (c CSP) Add(directive string, sources ...string) CSP

Add appends sources to a directive, returning the CSP for chaining.

func (CSP) String

func (c CSP) String() string

String renders the policy deterministically (directives sorted), so the header is stable across restarts and easy to assert in tests.

type Config

type Config struct {
	Resolver  *auth.Resolver
	LoginPath string // where a Page 401 redirects, e.g. "/login"
	// ClearSession discards the session cookie on a login redirect. A stale cookie that survives
	// the redirect is what turns the login page -- gated like everything else -- into a redirect
	// back to itself, nesting `next` deeper each hop. Clearing it makes the browser follow the
	// redirect with no credential, resolve as anonymous, and get the form. Optional: nil leaves
	// the cookie in place (the loop the app opts out of avoiding). Only the app knows the cookie's
	// name and flags, so it supplies the closure.
	ClearSession func(http.ResponseWriter)
}

Config carries what the router needs to resolve and gate every request.

type Errors added in v1.6.0

type Errors []Rule

Errors is an ordered table of rules: the first one that matches wins.

It is a slice and not a map, deliberately. errors.Is matches through wrapping, so two rules genuinely can both claim one error, and the tie must be broken by an order a reader can see. That order is also what makes With an override rather than an addition. See docs/design.md, FR-10.1.

func (Errors) Fail added in v1.6.0

func (e Errors) Fail(w http.ResponseWriter, r *http.Request, err error)

Fail renders err through the table. An error no rule matches is a logged 500 and never a message to the caller: a sentinel nobody mapped is a bug in the table, which the caller cannot fix, and a store error's own text says more about the inside of the service than anybody outside it should learn. Do not add a catch-all rule to spare the log line.

func (Errors) Match added in v1.6.0

func (e Errors) Match(err error) (code int, reason string, ok bool)

Match reports the status and the reason for err, and whether any rule claimed it. It matches through wrapping, so a store error a caller has annotated (fmt.Errorf("loading the target: %w", store.ErrNotFound)) still finds its rule.

func (Errors) Status added in v1.6.0

func (e Errors) Status(err error) error

Status renders err as an error a typed handler returns, rather than one it writes:

if err != nil {
    return nil, userErrors.Status(err)
}

It is Fail's other half, over the same table and under the same rule: an error no rule names is a logged 500, and the caller is told "internal error" and no more.

func (Errors) With added in v1.6.0

func (e Errors) With(err error, code int, reason string) Errors

With derives a table that answers differently for one sentinel, leaving the table it derives from untouched. A sentinel can mean different things to different verbs: store.ErrReservedUser is "cannot be deleted" to a DELETE and "can only be enabled or disabled" to a PATCH. With prepends, and the first matching rule answers -- which is what makes it an override rather than an addition.

type Kind

type Kind uint8

Kind selects how a denial is rendered for a raw route.

const (
	// JSON renders denials as the error envelope: unauthenticated -> 401,
	// authenticated-but-wrong-tier -> 403. It means "refuse as JSON rather than as a
	// page", not "this is the API": a raw route may answer JSON without being a typed
	// operation.
	JSON Kind = iota
	// Page renders denials for a browser: unauthenticated -> 303 to the login
	// path with a next parameter, authenticated-but-wrong-tier -> 403 HTML page.
	Page
)

type Limiter added in v1.2.0

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

Limiter is a fixed-window counter, keyed by whatever the caller decides the subject of the limit is: a client IP for a login form, an account for a mail that account can ask to have sent to itself.

It is deliberately not part of the Route contract: a route declares who may enter, and how often they may knock is the handler's business (docs/design.md, FR-5.6). It composes with, and does not replace, the per-account lockout in the store -- one stops a thousand guesses at one account, the other one guess at a thousand.

func NewLimiter added in v1.2.0

func NewLimiter(limit int, window time.Duration) *Limiter

NewLimiter allows limit events per key per window. A limit of zero or less disables it -- every call to Allow succeeds -- which is what an unconfigured brake should do.

func (*Limiter) Allow added in v1.2.0

func (l *Limiter) Allow(key string) bool

Allow records one event against key and reports whether it is within the limit. A refused event is still counted, so a caller that keeps hammering keeps the window open rather than sneaking through the moment it lapses.

func (*Limiter) Len added in v1.2.0

func (l *Limiter) Len() int

Len is how many keys the limiter is currently tracking, for the tests and a future gauge.

func (*Limiter) Limited added in v1.2.0

func (l *Limiter) Limited(key string) bool

Limited reports whether key is currently over its limit, without counting an event. It is what a caller uses when only failures should count: a login form checks Limited before it does any work, calls Allow when the attempt fails, and Reset when it succeeds. Counting successes too would rate-limit a busy office behind one NAT.

func (*Limiter) Reset added in v1.2.0

func (l *Limiter) Reset(key string)

Reset forgets a key. A successful sign-in calls it: the limiter is there to bound guessing, and whoever just proved a credential was not guessing.

func (*Limiter) RetryAfter added in v1.2.0

func (l *Limiter) RetryAfter(key string) time.Duration

RetryAfter is how long key must wait, rounded up to a second, and what the Retry-After header on a 429 carries. It is zero when the key is not limited.

func (*Limiter) Status added in v1.6.0

func (l *Limiter) Status(key, reason string) error

Status is the refusal a caller who has knocked too often gets from a typed handler: a 429 carrying Retry-After, returned rather than written. No handler should build one by hand -- only the limiter knows when the window lapses, and a forgotten Retry-After is a client that retries in a tight loop.

func (*Limiter) Sweep added in v1.2.0

func (l *Limiter) Sweep() int

Sweep drops the keys whose windows have lapsed. Nothing depends on it having run -- a lapsed window is judged on read -- so a missed sweep costs memory, never correctness. The library's background worker calls it.

type NavItem struct {
	Label  string
	Href   string
	Access auth.Access
}

NavItem is a header-menu entry, shown only to callers whose archetype is in Access. The library filters the menu with the same gate it routes with.

type Op added in v1.6.0

type Op struct {
	// ID is the operationId in the document, and the name of the method a generated
	// client will call. Changing it renames somebody's generated function.
	ID          string
	Method      string
	Path        string
	Summary     string
	Description string
	Tags        []string
	// Access is the set of archetypes admitted. There is no default, and the zero
	// value admits nobody -- see Register.
	Access auth.Access
	// Status is the success status. Zero means huma's default (200, or 204 when the
	// operation has no body).
	Status int
	// Errors are the statuses this operation can answer with, so they appear in the
	// document. The gate's own 401 and 403 are added for you.
	Errors []int
}

Op is one typed operation: what huma needs to describe it, plus the one thing the route contract adds -- who may enter.

type Route

type Route struct {
	Method  string
	Pattern string
	Handler http.HandlerFunc
	Access  auth.Access
	Kind    Kind
}

Route is one method+pattern, its handler, which archetypes may enter, and how a denial is rendered. It is the unit an application declares.

type Router

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

Router builds the ServeMux, wrapping each route in the archetype gate and a per-pattern metric label.

func NewRouter

func NewRouter(cfg Config) *Router

NewRouter creates a router with an empty mux.

func (*Router) Handle

func (rt *Router) Handle(route Route)

Handle registers a gated, instrumented route.

func (*Router) HandlePublic

func (rt *Router) HandlePublic(method, pattern string, h http.Handler)

HandlePublic registers a public, instrumented handler with no archetype gate and no credential resolution -- for /healthz and /assets, which must answer every caller (a bad token must not turn /healthz into a 401) yet still be counted.

func (*Router) HandleRaw

func (rt *Router) HandleRaw(method, pattern string, h http.Handler)

HandleRaw registers a handler with no archetype gate and no metric label (for /metrics itself, which must not measure its own scrape).

func (*Router) Mux

func (rt *Router) Mux() *http.ServeMux

Mux returns the underlying mux for wrapping with security headers and logging.

type Rule added in v1.6.0

type Rule struct {
	Err    error
	Code   int
	Reason string
}

Rule maps one sentinel to the status and the wording an API answers it with.

Jump to

Keyboard shortcuts

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