httpx

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 37 Imported by: 0

Documentation

Overview

Package httpx contains the HTTP layer: routing, middleware and handlers.

Index

Constants

View Source
const APIPrefix = "/api/v1"

APIPrefix is the versioned API root.

Variables

This section is empty.

Functions

func AsError

func AsError(err error, target any) bool

AsError is errors.As with the argument order handlers read more naturally.

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

func ClearSessionCookie(secure bool) *http.Cookie

ClearSessionCookie expires the session cookie. Attributes must match the original or the browser will not replace it.

func ClientIPFrom

func ClientIPFrom(ctx context.Context) netip.Addr

ClientIPFrom returns the resolved client address.

func IdentityFrom

func IdentityFrom(ctx context.Context) *auth.Identity

IdentityFrom returns the authenticated identity, or nil.

func NewRouter

func NewRouter(d Deps) http.Handler

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

func NewSessionCookie(token string, secure bool, maxAge int) *http.Cookie

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

func RealIP(trusted []netip.Prefix) func(http.Handler) http.Handler

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

func RequestTimeout(d time.Duration) func(http.Handler) http.Handler

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

func RequireAuth(next http.Handler) http.Handler

RequireAuth rejects requests with no identity.

func SecurityHeaders

func SecurityHeaders(cfg config.Config) func(http.Handler) http.Handler

SecurityHeaders sets the defensive headers every response carries.

func ServerTiming

func ServerTiming(enabled bool) func(http.Handler) http.Handler

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

func Session(a Authenticator, secure bool) func(http.Handler) http.Handler

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

type AuthAPI struct {
	Auth   *auth.Service
	Config config.Config
}

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)

func (*AuthAPI) Login

func (a *AuthAPI) Login(w http.ResponseWriter, r *http.Request)

func (*AuthAPI) Logout

func (a *AuthAPI) Logout(w http.ResponseWriter, r *http.Request)

func (*AuthAPI) Register

func (a *AuthAPI) Register(w http.ResponseWriter, r *http.Request)

Register creates an account, subject to SIGNUP_MODE.

func (*AuthAPI) Setup

func (a *AuthAPI) Setup(w http.ResponseWriter, r *http.Request)

Setup creates the first user. Available only while no users exist, so a fresh instance can be claimed without the operator editing the database, and closed permanently the moment it is used.

type Authenticator

type Authenticator interface {
	Authenticate(ctx context.Context, token string) (*auth.Identity, error)
}

Authenticator resolves credentials to an identity.

type ClickEvent

type ClickEvent struct {
	LinkID      uuid.UUID
	WorkspaceID uuid.UUID
	OccurredAt  time.Time
	IP          string
	UserAgent   string
	Referrer    string
	Language    string
	LatencyUS   int32
}

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

type DocsHandlers struct {
	UI *ui.Renderer
}

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.

func (*KeyAPI) Create

func (a *KeyAPI) Create(w http.ResponseWriter, r *http.Request)

Create issues a key. The response carries the token, and it is the only response that ever will.

func (*KeyAPI) List

func (a *KeyAPI) List(w http.ResponseWriter, r *http.Request)

func (*KeyAPI) Revoke

func (a *KeyAPI) Revoke(w http.ResponseWriter, r *http.Request)

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

func NewLimiters(cfg config.Config) Limiters

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

type LinkAPI struct {
	Links *link.Service
}

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) Archive

func (a *LinkAPI) Archive(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) Create

func (a *LinkAPI) Create(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) Delete

func (a *LinkAPI) Delete(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) DeleteTag

func (a *LinkAPI) DeleteTag(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) Get

func (a *LinkAPI) Get(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) GetDomain

func (a *LinkAPI) GetDomain(w http.ResponseWriter, r *http.Request)

GetDomain reports the link domain's settings.

func (*LinkAPI) List

func (a *LinkAPI) List(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) ListTags

func (a *LinkAPI) ListTags(w http.ResponseWriter, r *http.Request)

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) Restore

func (a *LinkAPI) Restore(w http.ResponseWriter, r *http.Request)

func (*LinkAPI) Update

func (a *LinkAPI) Update(w http.ResponseWriter, r *http.Request)

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

type StatsAPI struct {
	Reader *analytics.Reader
}

StatsAPI serves analytics reads.

func (*StatsAPI) LinkClicks

func (a *StatsAPI) LinkClicks(w http.ResponseWriter, r *http.Request)

func (*StatsAPI) LinkStats

func (a *StatsAPI) LinkStats(w http.ResponseWriter, r *http.Request)

func (*StatsAPI) Overview

func (a *StatsAPI) Overview(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) Dashboard

func (h *Web) Dashboard(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) KeyRevoke

func (h *Web) KeyRevoke(w http.ResponseWriter, r *http.Request)

func (*Web) KeysPage

func (h *Web) KeysPage(w http.ResponseWriter, r *http.Request)

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) LinksPage

func (h *Web) LinksPage(w http.ResponseWriter, r *http.Request)

func (*Web) LoginPage

func (h *Web) LoginPage(w http.ResponseWriter, r *http.Request)

func (*Web) LoginSubmit

func (h *Web) LoginSubmit(w http.ResponseWriter, r *http.Request)

func (*Web) Logout

func (h *Web) Logout(w http.ResponseWriter, r *http.Request)

func (*Web) PasswordChange

func (h *Web) PasswordChange(w http.ResponseWriter, r *http.Request)

func (*Web) RequireWebAuth

func (h *Web) RequireWebAuth(next http.Handler) http.Handler

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) SetupPage

func (h *Web) SetupPage(w http.ResponseWriter, r *http.Request)

func (*Web) SetupSubmit

func (h *Web) SetupSubmit(w http.ResponseWriter, r *http.Request)

Jump to

Keyboard shortcuts

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