Documentation
¶
Overview ¶
Package httpx contains the HTTP layer: routing, middleware and handlers.
Index ¶
- Constants
- func AsError(err error, target any) bool
- func BearerAuth(a Authenticator) func(http.Handler) http.Handler
- func ClearSessionCookie(secure bool) *http.Cookie
- func ClientIPFrom(ctx context.Context) netip.Addr
- func IdentityFrom(ctx context.Context) *auth.Identity
- func NewRouter(d Deps) http.Handler
- func NewSessionCookie(token string, secure bool, maxAge int) *http.Cookie
- func RateLimit(l *ratelimit.Limiter, name string, metrics *observability.Metrics, deny Deny) func(http.Handler) http.Handler
- func RealIP(trusted []netip.Prefix) func(http.Handler) http.Handler
- func RegisteredTopLevelPaths() []string
- func RequestTimeout(d time.Duration) func(http.Handler) http.Handler
- func RequireAuth(next http.Handler) http.Handler
- func SecurityHeaders(cfg config.Config) func(http.Handler) http.Handler
- func ServerTiming(enabled bool) func(http.Handler) http.Handler
- func Session(a Authenticator, secure bool) func(http.Handler) http.Handler
- func WriteError(w http.ResponseWriter, r *http.Request, err error)
- func WriteJSON(w http.ResponseWriter, status int, v any)
- func WriteProblem(w http.ResponseWriter, r *http.Request, p Problem)
- type AuthAPI
- func (a *AuthAPI) ChangePassword(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Login(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Logout(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Register(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Setup(w http.ResponseWriter, r *http.Request)
- type Authenticator
- type ClickEvent
- type ClickRecorder
- type Deny
- type Deps
- type DocsHandlers
- type Health
- type KeyAPI
- type Limiters
- type LinkAPI
- func (a *LinkAPI) Archive(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Create(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Delete(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteTag(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Get(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetDomain(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) List(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListTags(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Me(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Restore(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Update(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateDomain(w http.ResponseWriter, r *http.Request)
- type Problem
- type RedirectHandler
- type Report
- type RootRedirect
- type StatsAPI
- type Web
- func (h *Web) AccountPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) Dashboard(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) KeyCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) KeyRevoke(w http.ResponseWriter, r *http.Request)
- func (h *Web) KeysPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkArchive(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkDetail(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkRestore(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinksPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LoginPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LoginSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) Logout(w http.ResponseWriter, r *http.Request)
- func (h *Web) PasswordChange(w http.ResponseWriter, r *http.Request)
- func (h *Web) RequireWebAuth(next http.Handler) http.Handler
- func (h *Web) Root(w http.ResponseWriter, r *http.Request)
- func (h *Web) SetupPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) SetupSubmit(w http.ResponseWriter, r *http.Request)
Constants ¶
const APIPrefix = "/api/v1"
APIPrefix is the versioned API root.
Variables ¶
This section is empty.
Functions ¶
func BearerAuth ¶
func BearerAuth(a Authenticator) func(http.Handler) http.Handler
BearerAuth attaches an identity from an `Authorization: Bearer` API key.
Unlike Session it does reject, and the asymmetry is deliberate. A cookie that no longer resolves is an ordinary event — an expired login — so the request continues anonymously and whatever it reaches decides. A bearer token is an explicit, deliberate credential: continuing anonymously would answer a revoked key with "authentication required", which reads as "the endpoint needs auth" rather than "your key is dead", and sends the caller looking in the wrong place.
Runs before Session, so a request carrying both uses the key.
func ClearSessionCookie ¶
ClearSessionCookie expires the session cookie. Attributes must match the original or the browser will not replace it.
func ClientIPFrom ¶
ClientIPFrom returns the resolved client address.
func IdentityFrom ¶
IdentityFrom returns the authenticated identity, or nil.
func NewRouter ¶
NewRouter builds the application handler.
The structure is two handler trees, not one, and that split is the point.
The application tree carries session lookup, security headers and the rest. The redirect tree carries almost nothing: a request for /{alias} must not pay for a session query, a CSRF check or template machinery, because the budget for the entire response is 20ms and a session lookup alone is a database round trip.
Only RealIP is shared, because analytics needs the client address and resolving it is a header read.
Every top-level path registered here must also appear in internal/alias/reserved.txt, or a user could create an alias that shadows it. TestReservedListCoversRegisteredRoutes enforces that.
func NewSessionCookie ¶
NewSessionCookie builds the session cookie.
The __Host- prefix requires Secure, Path=/ and no Domain, and browsers enforce that: a cookie with the prefix and any of those wrong is silently discarded. SameSite=Lax rather than Strict so following a link to the dashboard from elsewhere does not appear signed out, which users read as a bug.
func RateLimit ¶
func RateLimit(l *ratelimit.Limiter, name string, metrics *observability.Metrics, deny Deny) func(http.Handler) http.Handler
RateLimit throttles requests by client address.
A nil limiter returns the handler untouched rather than a wrapper that always allows, so a disabled limit costs nothing at all — not even a context lookup per request.
The address comes from RealIP, which trusts X-Forwarded-For only from configured proxies. That matters more here than anywhere else: behind a proxy with TRUSTED_PROXIES unset, every request carries the proxy's address, all traffic shares one bucket, and the limit applies to the whole world at once.
func RealIP ¶
RealIP resolves the client address, honouring X-Forwarded-For only from trusted proxies.
The trust list defaults to empty, and that default is the important part. A service that believes X-Forwarded-For unconditionally lets any client claim any address, which defeats rate limiting and corrupts analytics — and the mistake is invisible until someone abuses it.
func RegisteredTopLevelPaths ¶
func RegisteredTopLevelPaths() []string
RegisteredTopLevelPaths lists the first path segment of every route the router registers, for the test that guards against an alias shadowing a real route.
Derived from the slices the router actually mounts rather than hand-written beside them. It is still not a walk of the live mux — net/http exposes no way to enumerate a ServeMux's patterns — but adding a dashboard route now updates this automatically, which is where routes are actually added.
func RequestTimeout ¶
RequestTimeout bounds how long a request may spend in the application tree.
A context deadline rather than http.TimeoutHandler, deliberately. The stdlib handler buffers the entire response in memory so it can replace it with a 503, which is a real cost on every request to gain a guarantee this service does not need: every database call here takes a context, so the deadline is what actually stops the work. What arrives at the client is then a 504 from the error mapper rather than a fabricated one from middleware.
The redirect tree is deliberately not wrapped. It has its own, much shorter budget — REDIRECT_TIMEOUT, applied where the resolver would touch Postgres — and a 15-second ceiling would be meaningless there.
A duration of zero disables it, returning the handler untouched.
func RequireAuth ¶
RequireAuth rejects requests with no identity.
func SecurityHeaders ¶
SecurityHeaders sets the defensive headers every response carries.
func ServerTiming ¶
ServerTiming emits a Server-Timing header carrying the server's own view of how long a response took.
Off by default, and that is a security default rather than a performance one: the header publishes internal timings to anyone who asks, and on a service where the interesting question is "does this alias exist" a timing difference is an answer. It is a development and debugging aid.
What it measures is the interval from entering this middleware to the handler deciding a status code — time to headers, not time to last byte. A header cannot be set after the response has started, so the alternative would be trailers, which no browser surfaces in the place a reader would look.
Never applied to the redirect tree: measuring it would mean instrumenting the path whose entire budget is 20ms, and the histogram already measures it more precisely.
func Session ¶
Session attaches an identity when a valid session cookie is present.
It never rejects: an anonymous request continues with no identity, and RequireAuth decides. Splitting the two keeps endpoints that behave differently for signed-in users from needing a second lookup.
func WriteError ¶
func WriteError(w http.ResponseWriter, r *http.Request, err error)
WriteError maps a service error to a problem document.
The mapping lives here and only here, which is what lets services return domain sentinels without knowing anything about HTTP. It is also the single place that decides what a client is allowed to learn: unrecognised errors become a flat 500 with the detail logged rather than returned, because an internal error string can carry table names, query fragments, or a DSN.
func WriteJSON ¶
func WriteJSON(w http.ResponseWriter, status int, v any)
WriteJSON emits a success response.
func WriteProblem ¶
func WriteProblem(w http.ResponseWriter, r *http.Request, p Problem)
WriteProblem emits a problem document.
Types ¶
type AuthAPI ¶
AuthAPI serves the JSON authentication endpoints.
The API surface comes first, before the HTML forms in M11, because Plan.md makes "every UI feature has API support" a success criterion. Building the form first and retrofitting an endpoint is how that criterion gets broken; the forms will post to these same service calls.
func (*AuthAPI) ChangePassword ¶
func (a *AuthAPI) ChangePassword(w http.ResponseWriter, r *http.Request)
type Authenticator ¶
type Authenticator interface {
Authenticate(ctx context.Context, token string) (*auth.Identity, error)
}
Authenticator resolves credentials to an identity.
type ClickEvent ¶
type ClickRecorder ¶
type ClickRecorder interface {
Record(ev ClickEvent)
}
ClickRecorder accepts a click for asynchronous recording.
Deliberately returns nothing. Recording must never fail a redirect, never block it, and never be waited on.
type Deny ¶
type Deny func(http.ResponseWriter, *http.Request)
Deny answers a request that a rate limit refused. Retry-After is already set.
A function rather than a fixed response, because the same limiter guards two surfaces: a refused API call should be a problem document, and a refused form post should be a page a person can read.
type Deps ¶
type Deps struct {
Config config.Config
Health *Health
Auth *auth.Service
Keys *auth.APIKeyService
Links *link.Service
Redirect *RedirectHandler
// RootRedirect serves the link host's root. Only consulted on a split-host
// deployment; nil leaves that root a 404, which is what it was before the
// setting existed.
RootRedirect *RootRedirect
Stats *analytics.Reader
Web *Web
// Metrics is optional. Nil disables instrumentation entirely rather than
// registering into a global registry, so two servers in one test process
// cannot collide.
Metrics *observability.Metrics
// Limits are the rate limits. The zero value enforces none, so a test that
// does not care about throttling does not have to opt out of it.
Limits Limiters
// Authenticator overrides how session cookies are resolved. Production
// leaves it nil and the auth service is used. The test that proves the
// redirect path performs no session lookup substitutes a tripwire here.
Authenticator Authenticator
}
Deps are the collaborators the router needs. An explicit struct so adding a dependency is a visible change rather than a hidden global.
type DocsHandlers ¶
DocsHandlers serves the API reference: Swagger UI at /docs and the OpenAPI document under the API prefix.
Swagger UI rather than a lighter viewer because the plan promised it and its try-it-out console genuinely earns its megabyte on a self-hosted product — paste an API key, exercise the API from the browser, no curl. The assets are vendored and checksum-pinned like htmx; the renderer serves them fingerprinted from the same embedded static tree as everything else.
func (*DocsHandlers) Page ¶
func (d *DocsHandlers) Page(w http.ResponseWriter, r *http.Request)
func (*DocsHandlers) SpecJSON ¶
func (d *DocsHandlers) SpecJSON(w http.ResponseWriter, r *http.Request)
SpecJSON serves the contract in the form tooling asks for.
func (*DocsHandlers) SpecYAML ¶
func (d *DocsHandlers) SpecYAML(w http.ResponseWriter, r *http.Request)
SpecYAML serves the contract as authored.
type Health ¶
type Health struct {
DB *pgxpool.Pool
Redis *goredis.Client
// contains filtered or unexported fields
}
Health serves the liveness and readiness endpoints.
func (*Health) Live ¶
func (h *Health) Live(w http.ResponseWriter, _ *http.Request)
Live handles GET /healthz.
Liveness answers exactly one question: is this process wedged? It deliberately touches neither Postgres nor Redis. If it did, a database outage would cause the orchestrator to kill and restart every replica simultaneously, which turns a recoverable dependency failure into a much worse outage.
func (*Health) Ready ¶
func (h *Health) Ready(w http.ResponseWriter, r *http.Request)
Ready handles GET /readyz: should traffic be routed here?
func (*Health) StartDraining ¶
func (h *Health) StartDraining()
StartDraining flips readiness to 503 so a load balancer deregisters this instance before the HTTP server stops accepting connections.
type KeyAPI ¶
type KeyAPI struct {
Keys *auth.APIKeyService
}
KeyAPI serves /api/v1/api-keys.
Thin, like the other handlers: every rule about who may mint a key and which scopes they may grant lives in the service, so the dashboard's key page in M11 inherits the same behaviour by calling the same methods.
type Limiters ¶
type Limiters struct {
// Login guards the endpoints that verify a credential.
Login *ratelimit.Limiter
// API guards everything under /api/v1.
API *ratelimit.Limiter
// NotFound throttles addresses probing for aliases that do not exist. It is
// enforced inside the redirect handler rather than by middleware, because
// only a miss may be charged and middleware cannot tell a miss from a hit
// without inspecting the response it is wrapping.
NotFound *ratelimit.Limiter
}
Limiters are the request limits the server enforces. A nil member means that limit is off, which is the whole reason ratelimit.New returns nil for a rate of zero: there is no second "enabled" flag to disagree with the number.
func NewLimiters ¶
NewLimiters builds the limits from configuration.
One construction site for all three, so the composition root can hand the same values to the router, the redirect handler and the metrics collector without any of them re-deriving a limit from config.
func (Limiters) Stats ¶
func (l Limiters) Stats() map[string]observability.LimiterStats
Stats returns the enabled limiters for the metrics collector, keyed by the label they report under.
Disabled limits are omitted rather than passed as nil pointers: a nil pointer in an interface is not a nil interface, so a disabled limiter would otherwise be collected as a working one reporting zeros.
type LinkAPI ¶
LinkAPI serves /api/v1/links and /api/v1/tags.
Handlers stay thin on purpose: they parse, call the service, and map the result. Every authorization and validation decision lives in the service, so the dashboard handlers added in M11 get identical behaviour by calling the same methods rather than by remembering to repeat the checks.
func (*LinkAPI) GetDomain ¶
func (a *LinkAPI) GetDomain(w http.ResponseWriter, r *http.Request)
GetDomain reports the link domain's settings.
func (*LinkAPI) Me ¶
func (a *LinkAPI) Me(w http.ResponseWriter, r *http.Request)
Me returns the current identity, including its permissions so a client can render only the actions the user can actually perform.
func (*LinkAPI) UpdateDomain ¶
func (a *LinkAPI) UpdateDomain(w http.ResponseWriter, r *http.Request)
UpdateDomain sets or clears the root redirect.
PATCH with a required field rather than PUT of the whole object: there is one setting here today, and a body that omits it should mean "change nothing" rather than "clear it".
type Problem ¶
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Errors []domain.FieldError `json:"errors,omitempty"`
}
Problem is an RFC 9457 problem detail.
One representation for every API error, so a client can branch on `type` rather than on prose, and field-level failures arrive in a shape a form can render directly.
type RedirectHandler ¶
type RedirectHandler struct {
Resolver *redirect.Resolver
// DomainID is resolved once at boot. Looking it up per request would add a
// query to the path this whole design exists to keep short.
DomainID uuid.UUID
Status int
Logger *slog.Logger
// LogSample logs one in N successful redirects; 0 disables. Logging every
// redirect at 2,000 rps produces more bytes than the redirects themselves.
LogSample int64
// Recorder receives click events. Nil until M8.
Recorder ClickRecorder
// Metrics is optional; a nil value makes every observation a no-op. This
// is the SLO's own measurement point, so it lives here rather than in
// middleware: the outer view includes the router's dispatch, and the
// number the target names is the time to resolve and answer.
Metrics *observability.Metrics
// NotFoundLimiter throttles addresses that keep asking for aliases which do
// not exist. Optional; nil disables it and costs nothing.
//
// It lives here rather than in middleware because only a miss may be charged.
// A hit must never spend a token — otherwise a popular link would throttle
// its own audience — and middleware cannot tell a hit from a miss without
// intercepting the response.
NotFoundLimiter *ratelimit.Limiter
// contains filtered or unexported fields
}
RedirectHandler serves GET|HEAD /{alias}.
This is the hot path and the whole reason for the router's shape. It runs with no session lookup, no CSRF check and no template rendering, because each of those is a cost the 20ms budget cannot absorb — the session check alone would be a database round trip on every visit.
func (*RedirectHandler) Location ¶
func (h *RedirectHandler) Location(w http.ResponseWriter, target string, status int)
Location writes the redirect response.
func (*RedirectHandler) ServeHTTP ¶
func (h *RedirectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
type Report ¶
type Report struct {
Status string `json:"status"`
Version string `json:"version"`
Dependencies map[string]string `json:"dependencies,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
Report is the readiness response body.
type RootRedirect ¶
type RootRedirect struct {
// Load reads the current destination. Empty means "not configured", which
// is answered 404.
Load func(context.Context) (string, error)
// TTL bounds staleness if an invalidation is ever missed. Zero means one
// minute.
TTL time.Duration
// Status is the redirect code, defaulting to 302. It follows the instance's
// configured default rather than being pinned here: an operator who chose
// 301 for links made that decision once already. The 302 default is the
// product's usual reasoning — a 301 cached in browsers and intermediaries
// cannot be recalled — and it applies most strongly to this destination,
// which is the one most likely to be repointed later.
Status int
// contains filtered or unexported fields
}
RootRedirect serves the root of the link domain.
It lives on the redirect tree, under the same latency budget as an alias, and the bare domain is a URL crawlers and scanners ask for constantly — so the value is cached rather than read per request. A database round trip here would put one on the most-probed path in the product for a value that changes approximately never.
Refreshed on a TTL and invalidated on write, the same shape as a link snapshot. The TTL alone would be enough for correctness and would leave an operator reloading the page they just configured and seeing the old answer.
func (*RootRedirect) InvalidateRoot ¶
func (h *RootRedirect) InvalidateRoot()
InvalidateRoot drops the cached value.
func (*RootRedirect) ServeHTTP ¶
func (h *RootRedirect) ServeHTTP(w http.ResponseWriter, r *http.Request)
type StatsAPI ¶
StatsAPI serves analytics reads.
func (*StatsAPI) LinkClicks ¶
func (a *StatsAPI) LinkClicks(w http.ResponseWriter, r *http.Request)
type Web ¶
type Web struct {
UI *ui.Renderer
Config config.Config
Auth *auth.Service
Keys *auth.APIKeyService
Links *link.Service
Stats *analytics.Reader
}
Web serves the HTML dashboard.
Every handler here is a thin skin over the same service calls the JSON API makes. That is the mechanism behind the "every UI feature has API support" success criterion: the two surfaces cannot diverge because there is nothing in either of them to diverge — validation, authorization and behaviour all live one layer down.
func (*Web) AccountPage ¶
func (h *Web) AccountPage(w http.ResponseWriter, r *http.Request)
func (*Web) DomainUpdate ¶
func (h *Web) DomainUpdate(w http.ResponseWriter, r *http.Request)
DomainUpdate handles the link-domain form.
func (*Web) KeyCreate ¶
func (h *Web) KeyCreate(w http.ResponseWriter, r *http.Request)
KeyCreate mints a key and renders the page directly — no redirect.
A redirect would drop the token, which exists only in this response; the alternative is stashing it in a flash cookie, which would put a live credential in a Set-Cookie header for nothing. The cost is that refreshing this response re-submits and mints a second key, which is visible in the list and revocable, and the browser warns before doing it.
func (*Web) LinkArchive ¶
func (h *Web) LinkArchive(w http.ResponseWriter, r *http.Request)
func (*Web) LinkCreate ¶
func (h *Web) LinkCreate(w http.ResponseWriter, r *http.Request)
func (*Web) LinkDelete ¶
func (h *Web) LinkDelete(w http.ResponseWriter, r *http.Request)
func (*Web) LinkDetail ¶
func (h *Web) LinkDetail(w http.ResponseWriter, r *http.Request)
func (*Web) LinkRestore ¶
func (h *Web) LinkRestore(w http.ResponseWriter, r *http.Request)
func (*Web) LinkUpdate ¶
func (h *Web) LinkUpdate(w http.ResponseWriter, r *http.Request)
func (*Web) LoginSubmit ¶
func (h *Web) LoginSubmit(w http.ResponseWriter, r *http.Request)
func (*Web) PasswordChange ¶
func (h *Web) PasswordChange(w http.ResponseWriter, r *http.Request)
func (*Web) RequireWebAuth ¶
RequireWebAuth is RequireAuth for pages: anonymous requests are sent to the login form with a way back, not handed a JSON problem they cannot read.
func (*Web) Root ¶
func (h *Web) Root(w http.ResponseWriter, r *http.Request)
Root sends / wherever makes sense for the visitor.
func (*Web) SetupSubmit ¶
func (h *Web) SetupSubmit(w http.ResponseWriter, r *http.Request)