Documentation
¶
Overview ¶
Package httpx provides the HTTP foundation of a gorbital app: a server that runs under app.Run with safe timeouts, security middleware, and the RFC 9457 problem+json error contract with an application-owned error mapping (ADR-0018).
A typical middleware chain, outermost first:
handler := httpx.Chain(mux,
httpx.Recover(logger),
httpx.RequestID(),
httpx.AccessLog(logger),
httpx.SecureHeaders(httpx.SecureHeadersOptions{}),
cors,
crossOrigin,
httpx.BodyLimit(1<<20),
)
Stability: stable (ADR-0015, ADR-0054).
Index ¶
- Constants
- Variables
- func Chain(h http.Handler, middlewares ...Middleware) http.Handler
- func DefaultCode(status int) string
- func ParseTrustedProxies(list string) ([]netip.Prefix, error)
- func WriteProblem(w http.ResponseWriter, r *http.Request, p *Problem)
- type AccessNote
- type CORSOptions
- type Captured
- type FieldError
- type MaintenanceOptions
- type Mapper
- type Mapping
- type Middleware
- func AccessLog(logger *slog.Logger) Middleware
- func BodyLimit(n int64) Middleware
- func CORS(opts CORSOptions) (Middleware, error)
- func CrossOrigin(trustedOrigins ...string) (Middleware, error)
- func Maintenance(opts MaintenanceOptions) Middleware
- func Recover(logger *slog.Logger) Middleware
- func RequestID() Middleware
- func RequestIDFrom(trusted []netip.Prefix) Middleware
- func SecureHeaders(opts SecureHeadersOptions) Middleware
- func TrustedProxies(trusted []netip.Prefix) Middleware
- type Problem
- type SecureHeadersOptions
- type Server
- type ServerOption
Examples ¶
Constants ¶
const ( DefaultReadHeaderTimeout = 5 * time.Second DefaultReadTimeout = 30 * time.Second DefaultWriteTimeout = 60 * time.Second DefaultIdleTimeout = 120 * time.Second DefaultShutdownTimeout = 20 * time.Second DefaultMaxHeaderBytes = 1 << 20 )
Server timeouts used by NewServer unless overridden.
const DefaultMaintenanceMessage = "the service is down for maintenance; try again later"
DefaultMaintenanceMessage is the problem detail Maintenance sends while MaintenanceOptions.Message is unset or empty.
const ProblemContentType = "application/problem+json"
ProblemContentType is the media type of problem responses.
Variables ¶
var ErrTrustAll = errors.New("httpx: a trusted proxy range can't cover every address")
ErrTrustAll reports a trusted-proxy range covering every address, which would let any client choose its own IP.
Functions ¶
func Chain ¶
func Chain(h http.Handler, middlewares ...Middleware) http.Handler
Chain wraps h with middlewares. The first middleware is the outermost: it sees the request first and the response last.
func DefaultCode ¶
DefaultCode returns the generic code for a status without a mapping, such as "not_found" for 404.
func ParseTrustedProxies ¶
ParseTrustedProxies reads a comma-separated list of CIDR ranges or single addresses, such as "10.0.0.0/8, 192.0.2.10". An empty list trusts nothing. Ranges are masked ("10.0.0.5/8" is 10.0.0.0/8), and IPv4 addresses written in IPv6 form ("::ffff:10.0.0.5") become IPv4, as peer addresses are compared; an address with a zone ("fe80::1%en0") is refused, as it could never match one. It refuses ranges covering every IPv4 or IPv6 address (ErrTrustAll), including "::ffff:0.0.0.0/96", which is every IPv4 address written in IPv6 form.
A mapped range shorter than /96 mixes the two families and is refused: netip would re-base it on a range the operator never wrote ("::ffff:10.0.0.0/8" masks to "::/8", which covers ::1).
func WriteProblem ¶
func WriteProblem(w http.ResponseWriter, r *http.Request, p *Problem)
WriteProblem writes p as application/problem+json, filling the request ID from the request context when empty.
Types ¶
type AccessNote ¶
type AccessNote struct {
// contains filtered or unexported fields
}
AccessNote carries attributes from later middleware and handlers back to AccessLog's record for the request: the authenticated user, set by the auth middleware, so request logs can be filtered by user. It is safe for concurrent use.
func AccessNoteFrom ¶
func AccessNoteFrom(ctx context.Context) *AccessNote
AccessNoteFrom returns the request's note, or nil without AccessLog.
func (*AccessNote) Add ¶
func (n *AccessNote) Add(attrs ...slog.Attr)
Add adds attrs to the request's log record; a nil note ignores them. A key added twice keeps the last value.
type CORSOptions ¶
type CORSOptions struct {
// AllowedOrigins are exact origins such as "https://app.example.com".
// Empty disables CORS.
AllowedOrigins []string
AllowedMethods []string // default: GET, POST, PUT, PATCH, DELETE
AllowedHeaders []string // default: Authorization, Content-Type, X-Request-ID
ExposedHeaders []string // default: X-Request-ID, Retry-After
// AllowCredentials allows cookies. It can't be combined with a wildcard origin.
AllowCredentials bool
MaxAge time.Duration // preflight cache; default 10 minutes
}
CORSOptions configure CORS.
type Captured ¶ added in v0.2.0
type Captured struct {
http.ResponseWriter
// contains filtered or unexported fields
}
Captured is a response writer that records what the handler wrote, for middleware that reads the response after the handler returns: its status, whether headers were sent, and the body size. Create one with Capture.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"gorbital.dev/httpx"
)
func main() {
cw := httpx.Capture(httptest.NewRecorder())
cw.WriteHeader(http.StatusCreated)
_, _ = cw.Write([]byte(`{"id":"bok_1"}`))
fmt.Println(cw.Status(), cw.Bytes(), cw.WroteHeader())
}
Output: 201 14 true
func Capture ¶ added in v0.2.0
func Capture(w http.ResponseWriter) *Captured
Capture returns w wrapped to record the response, or w itself when it is already a *Captured, so several middlewares share one record. Pass the result to the next handler:
cw := httpx.Capture(w)
next.ServeHTTP(cw, r)
if cw.Status() >= 500 { … }
Example ¶
package main
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"gorbital.dev/httpx"
)
func main() {
// logErrors logs every response with a 5xx status.
logErrors := func(logger *slog.Logger) httpx.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cw := httpx.Capture(w)
next.ServeHTTP(cw, r)
if cw.Status() >= 500 {
logger.ErrorContext(r.Context(), "server error", "status", cw.Status(), "bytes", cw.Bytes())
}
})
}
}
failing := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
})
rec := httptest.NewRecorder()
logErrors(slog.New(slog.DiscardHandler))(failing).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/books", nil))
fmt.Println(rec.Code)
}
Output: 503
func (*Captured) Bytes ¶ added in v0.2.0
Bytes returns the number of body bytes written.
Example ¶
package main
import (
"fmt"
"net/http/httptest"
"gorbital.dev/httpx"
)
func main() {
cw := httpx.Capture(httptest.NewRecorder())
_, _ = fmt.Fprint(cw, "hello, ")
_, _ = fmt.Fprint(cw, "world")
fmt.Println(cw.Bytes())
}
Output: 12
func (*Captured) Status ¶ added in v0.2.0
Status returns the status the handler sent: 200 when it wrote a body without calling WriteHeader, or wrote nothing at all.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"gorbital.dev/httpx"
)
func main() {
cw := httpx.Capture(httptest.NewRecorder())
fmt.Println(cw.Status()) // nothing written yet: 200, as net/http sends
cw.WriteHeader(http.StatusNotFound)
fmt.Println(cw.Status())
}
Output: 200 404
func (*Captured) Unwrap ¶ added in v0.2.0
func (w *Captured) Unwrap() http.ResponseWriter
Unwrap supports http.ResponseController (flushing, deadlines).
func (*Captured) Write ¶ added in v0.2.0
Write records an implicit 200 status and the number of bytes written.
func (*Captured) WriteHeader ¶ added in v0.2.0
WriteHeader records the first status code and forwards it.
func (*Captured) WroteHeader ¶ added in v0.2.0
WroteHeader reports whether the handler has sent the status and headers, after which a middleware can no longer change them.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"gorbital.dev/httpx"
)
func main() {
// A middleware that sets a header only while it still can.
addVersion := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cw := httpx.Capture(w)
next.ServeHTTP(cw, r)
if !cw.WroteHeader() {
cw.Header().Set("X-Version", "1")
cw.WriteHeader(http.StatusNoContent)
}
})
}
rec := httptest.NewRecorder()
addVersion(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/", nil))
fmt.Println(rec.Code, rec.Header().Get("X-Version"))
}
Output: 204 1
type FieldError ¶
type FieldError struct {
Location string `json:"location,omitempty" doc:"Where the error occurred" example:"body.name"`
Message string `json:"message" doc:"What is wrong" example:"expected length >= 1"`
}
FieldError describes one invalid input field. It never echoes the submitted value, which may be a password or personal data.
type MaintenanceOptions ¶ added in v0.2.0
type MaintenanceOptions struct {
// Enabled turns maintenance mode on. Nil never turns it on.
Enabled config.Value[bool]
// Message is the problem detail clients see; nil or empty sends
// DefaultMaintenanceMessage.
Message config.Value[string]
// RetryAfter is sent in the Retry-After header, in whole seconds; nil or
// less than a second sends none.
RetryAfter config.Value[time.Duration]
// Open are the paths that keep being served while maintenance mode is
// on. A path ending in a slash covers everything under it, so "/ops/"
// keeps /ops/settings open; other paths match exactly. Keep health
// checks open, or load balancers take every instance out of rotation.
Open []string
}
MaintenanceOptions configures Maintenance. The values are read on every request, so runtime settings (config.Value) switch maintenance mode on every instance without a restart.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"gorbital.dev/config"
"gorbital.dev/httpx"
)
func main() {
opts := httpx.MaintenanceOptions{
Enabled: config.Static(true),
Message: config.Static("Back at 10:00 UTC"),
// Health checks exactly, and everything under /ops/.
Open: []string{"/livez", "/readyz", "/ops/"},
}
rec := httptest.NewRecorder()
httpx.Maintenance(opts)(http.NotFoundHandler()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/books", nil))
fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
}
Output: 503 application/problem+json
type Mapper ¶
type Mapper struct {
// contains filtered or unexported fields
}
Mapper turns errors into problems using application-owned mappings. Errors without a mapping become a generic 500 and are logged once with the request ID. It is safe for concurrent use.
func (*Mapper) Add ¶
Add registers mappings. Each needs a non-nil error, a 4xx or 5xx status and a snake_case code. Each error is mapped once; several errors may share a code only with the same status, such as "unauthenticated" returned by different modules.
func (*Mapper) Match ¶
Match returns the problem for err if err is (or wraps) a *Problem or a mapped error. It never logs.
type Mapping ¶
A Mapping maps a sentinel error (matched with errors.Is) to an HTTP status and stable code. Detail defaults to the error's message.
type Middleware ¶
Middleware wraps an http.Handler.
func AccessLog ¶
func AccessLog(logger *slog.Logger) Middleware
AccessLog logs one line per request: method, path, route pattern, status, duration, response size, request ID and what later middleware noted with AccessNoteFrom (the authenticated user). Query strings and bodies are never logged.
func BodyLimit ¶
func BodyLimit(n int64) Middleware
BodyLimit rejects request bodies larger than n bytes with a 413 problem. Bodies without a declared length are cut off at n bytes while reading.
func CORS ¶
func CORS(opts CORSOptions) (Middleware, error)
CORS returns middleware allowing cross-origin requests from an explicit allowlist. A "*" origin is rejected when credentials are allowed.
func CrossOrigin ¶
func CrossOrigin(trustedOrigins ...string) (Middleware, error)
CrossOrigin protects against cross-site request forgery using the browser's Sec-Fetch-Site and Origin headers (http.CrossOriginProtection). Non-browser clients, which send neither header, are allowed. Denied requests receive a 403 problem with code "cross_origin_request_denied".
func Maintenance ¶ added in v0.2.0
func Maintenance(opts MaintenanceOptions) Middleware
Maintenance answers every request with 503 and the problem code maintenance while opts.Enabled is on, except requests for opts.Open paths (ADR-0051). It reads the values from opts on each request and does no other work, so it costs no database query when the values are runtime settings.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"time"
"gorbital.dev/config"
"gorbital.dev/httpx"
)
func main() {
books := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "books") })
// In an app, Enabled, Message and RetryAfter are runtime settings, so
// operators switch every instance without a restart.
h := httpx.Maintenance(httpx.MaintenanceOptions{
Enabled: config.Static(true),
RetryAfter: config.Static(5 * time.Minute),
Open: []string{"/livez", "/readyz"},
})(books)
for _, path := range []string{"/v1/books", "/readyz"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
fmt.Println(path, rec.Code, rec.Header().Get("Retry-After"))
}
}
Output: /v1/books 503 300 /readyz 200
func Recover ¶
func Recover(logger *slog.Logger) Middleware
Recover turns a panic into a logged error and a 500 problem response. http.ErrAbortHandler panics are re-raised, as net/http expects.
func RequestID ¶
func RequestID() Middleware
RequestID generates a request ID, stores it in the request context and echoes it in the response header. It ignores incoming X-Request-ID headers, so clients can't give their requests another request's ID in logs, audit events and jobs; use RequestIDFrom to accept them from trusted callers.
func RequestIDFrom ¶
func RequestIDFrom(trusted []netip.Prefix) Middleware
RequestIDFrom is RequestID that accepts a valid incoming X-Request-ID from requests whose client address is in trusted: gateways and internal services that assign request IDs. Match addresses as this middleware sees them, after TrustedProxies. Other requests get a generated ID.
func SecureHeaders ¶
func SecureHeaders(opts SecureHeadersOptions) Middleware
SecureHeaders sets security headers suitable for a JSON API. HTML routes such as /docs set their own Content-Security-Policy.
func TrustedProxies ¶
func TrustedProxies(trusted []netip.Prefix) Middleware
TrustedProxies sets each request's RemoteAddr to its client's address when it arrives through trusted proxies (ADR-0052). For a request whose peer is in trusted, it walks X-Forwarded-For from the right, skips trusted addresses, and uses the first untrusted one. Requests from other peers keep their address and their forwarding headers are ignored, so clients can't choose their IP. With no trusted ranges it changes nothing.
Install it first, after recovery, so logs, audit events and rate limits see the client.
It canonicalises the ranges as ParseTrustedProxies does, and skips one that can't be: build the list with ParseTrustedProxies, which reports those as errors, rather than passing prefixes straight in.
type Problem ¶
type Problem struct {
Type string `json:"type,omitempty" doc:"URI identifying the problem type"`
Title string `json:"title" doc:"Short summary of the problem type" example:"Conflict"`
Status int `json:"status" doc:"HTTP status code" example:"409"`
Code string `json:"code" doc:"Stable machine-readable error code" example:"project_name_taken"`
Detail string `json:"detail,omitempty" doc:"Human-readable explanation" example:"project name is already taken"`
RequestID string `json:"request_id,omitempty" doc:"Correlates with server logs and traces" example:"req_9f86d081884c7d65"`
Errors []FieldError `json:"errors,omitempty" doc:"Field-level validation errors"`
}
Problem is an RFC 9457 problem details response extended with a stable machine-readable code and the request ID. Codes are public API; titles and details are not (ADR-0015).
func NewProblem ¶
NewProblem returns a problem with the standard title for status.
func (*Problem) ContentType ¶
ContentType returns application/problem+json for JSON responses.
type SecureHeadersOptions ¶
type SecureHeadersOptions struct {
// HSTSMaxAge enables Strict-Transport-Security when positive. Enable it
// only when the app is served exclusively over HTTPS.
HSTSMaxAge time.Duration
}
SecureHeadersOptions configure SecureHeaders.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is an HTTP server that implements app.Runner.
func NewServer ¶
func NewServer(addr string, handler http.Handler, opts ...ServerOption) *Server
NewServer returns a server for handler listening on addr.
func (*Server) Addr ¶
Addr returns the listening address once Server.Run has started listening, blocking until then or until ctx is done. It is useful with port 0 in tests.
type ServerOption ¶
type ServerOption interface {
// contains filtered or unexported methods
}
A ServerOption configures a Server.
func WithErrorLogger ¶
func WithErrorLogger(logger *slog.Logger) ServerOption
WithErrorLogger routes the server's internal errors (for example TLS handshake failures) to logger.
func WithShutdownTimeout ¶
func WithShutdownTimeout(d time.Duration) ServerOption
WithShutdownTimeout sets how long graceful shutdown may take. Default: DefaultShutdownTimeout.
func WithTimeouts ¶
func WithTimeouts(readHeader, read, write, idle time.Duration) ServerOption
WithTimeouts overrides read-header, read, write and idle timeouts. Zero values keep the defaults.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package ipfilter refuses requests by client address with 403 ip_not_allowed (ADR-0085):
|
Package ipfilter refuses requests by client address with 403 ip_not_allowed (ADR-0085): |
|
Package timeout gives each request a deadline and answers 503 request_timeout when the handler hasn't started its response by then (ADR-0085):
|
Package timeout gives each request a deadline and answers 503 request_timeout when the handler hasn't started its response by then (ADR-0085): |