handler

package
v0.96.0 Latest Latest
Warning

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

Go to latest
Published: Nov 7, 2026 License: MIT Imports: 26 Imported by: 2

Documentation

Overview

Package handler is part of the GoFastr framework. See https://github.com/DonaldMurillo/gofastr for documentation.

Index

Constants

This section is empty.

Variables

View Source
var (
	ReasonInvalidJSON          = i18n.Define("error.invalid_json", "invalid JSON: {{detail}}")
	ReasonJSONMultipleValues   = i18n.Define("error.json_multiple_values", "invalid JSON: body must contain exactly one JSON value")
	ReasonJSONDuplicateKey     = i18n.Define("error.json_duplicate_key", "invalid JSON: duplicate key {{key}}")
	ReasonJSONUnknownField     = i18n.Define("error.json_unknown_field", "invalid JSON: unknown field {{key}}")
	ReasonJSONFoldedKeys       = i18n.Define("error.json_folded_keys", "invalid JSON: keys {{first}} and {{second}} name the same field")
	ReasonInvalidParameter     = i18n.Define("error.invalid_parameter", "invalid {{source}} parameter {{name}}: {{detail}}")
	ReasonParameterControlByte = i18n.Define("error.parameter_control_byte", "invalid {{source}} parameter {{name}}: it carries a byte a {{source}} value never may")
	ReasonInvalidHeader        = i18n.Define("error.invalid_header", "invalid header {{name}}: {{detail}}")
	ReasonInvalidParams        = i18n.Define("error.invalid_params", "invalid params: {{detail}}")
)

The request-binding refusals, under "error.<code>". ReasonInvalidJSON's detail param is a JSONErrorDetail phrase, never the decoder's own text.

View Source
var (
	ReasonCrossSiteRefused       = i18n.Define("error.cross_site", "cross-site request refused")
	ReasonAuthenticationRequired = i18n.Define("error.authentication_required", "authentication required")
	ReasonMissingPermission      = i18n.Define("error.missing_permission", "access denied: missing permission {{permission}}")
)

Refusals shared by every route family that guards a write or a caller.

View Source
var ReasonValidationFailed = i18n.Define("error.validation_failed", "validation failed")

ReasonValidationFailed is the message of a 400 that carries field-level violations.

Functions

func AddPrincipalCheck added in v0.87.0

func AddPrincipalCheck(ctx context.Context, step PrincipalCheck) context.Context

AddPrincipalCheck appends a derivation step that runs after the installed identity check, on the context that check returned. Middleware that derives request state from the principal, such as access.Middleware resolving roles, uses it so the derived state is recomputed for the refreshed principal rather than replayed from connect time.

func Bind

func Bind(r *http.Request, dst any) error

Bind populates dst by merging values from all sources: 1. Header values (via `header:"X-Request-ID"` tag) 2. Query parameters (via `query:"name"` tag) 3. Path parameters (via `path:"id"` tag) 4. JSON body (decodes entire struct, highest priority)

Sources are applied in the order above; later present values overwrite earlier values. An absent JSON property preserves the lower-priority value.

A tagged field whose type implements encoding.TextUnmarshaler (time.Time, a UUID, a validated enum), directly or through a pointer, binds through UnmarshalText; its error is a 400 for that parameter. An empty query or header value (`?page=`) is the same as an absent one: the field keeps its zero value and a pointer stays nil. The tag option `,empty` (`query:"q,empty"`) binds an explicit empty instead: "" for a string, the zero value for any other kind, and a non-nil pointer to it for a pointer field. Any other tag option, or ,empty on a path tag, is an error on every bind.

If the body is present but not valid JSON, a 400 error is returned. If dst is not a non-nil pointer, Bind returns an error.

func BodyReadDetail added in v0.96.0

func BodyReadDetail(err error) error

BodyReadDetail is JSONErrorDetail for a failure reading the body before any decode ran: over its cap, cut off mid-stream, or a read that failed (a reset connection). Like JSONErrorDetail it never carries the error's own text. A nil err is nil.

func CheckObjectKeys added in v0.82.0

func CheckObjectKeys(data []byte, fold func(string) string) error

CheckObjectKeys is CheckTopLevelKeys applied to every object at every nesting depth: no object anywhere in the value may repeat a key or hold two keys that fold to the same name. Non-object values and tokenisation failures return nil so the caller's decoder reports them under its own error contract.

func CheckTopLevelKeys added in v0.82.0

func CheckTopLevelKeys(data []byte, fold func(string) string) error

CheckTopLevelKeys walks the top level of a JSON object and refuses any key that repeats, or that collides with an earlier key once both are passed through fold. Callers with their own key normalisation (crud folds camelCase and snake_case spellings of one column onto each other) pass that normaliser as fold; nil means exact keys only. Non-object bodies and tokenisation failures return nil so the caller's decoder reports them under its own error contract.

func DecodeStrict added in v0.82.0

func DecodeStrict(r io.Reader, dst any) error

DecodeStrict reads one JSON value from r into dst with the same top-level key rule Bind applies to request bodies, for every decode site that is not a plain handler.Bind consumer: JSON-RPC envelopes, websocket frames, buffered bodies replayed through a helper, dev-tool endpoints. Stdlib encoding/json keeps the LAST duplicate key and matches struct tags case-insensitively, so {"action":"safe", "Action":"danger"} runs the second action while a reviewer (or an intercepting log) reads the first. The ambiguity itself is the bug; refusing it is the only resolution that does not privilege one parser's pick.

Struct destinations: every top-level key must exactly match a json tag (case-folded spellings and unknown keys are refused, as Bind does) and no key may repeat. Map and other destinations: no key may repeat, and no two keys may fold to the same name under ASCII case folding. Bodies that are not a top-level object decode unchanged. Errors are *Error with code 400, so a handler can hand them to respond/Error directly.

Size is the caller's job: wrap the reader in http.MaxBytesReader (or io.LimitReader) before calling. The read error is wrapped, not flattened, so a caller can still see the cap's *http.MaxBytesError through errors.As and answer 413 instead of the 400 the envelope carries. The probes that pinned each surface live beside the callers as *_security_test.go.

func FoldJSONKey added in v0.96.0

func FoldJSONKey(key string) string

FoldJSONKey folds an object key the way encoding/json matches it to a struct field: an ASCII letter by case, any other rune to the smallest member of its Unicode fold set, so ſ (U+017F) lands on S and the Kelvin sign on K. Two keys fold equal exactly when bytes.EqualFold says so. Pass it as the fold to CheckObjectKeys or CheckTopLevelKeys ahead of a decode into a struct; strings.ToLower misses ſ.

func GetLogger

func GetLogger(ctx context.Context) (any, bool)

GetLogger retrieves the logger from the context.

func GetRequestID

func GetRequestID(ctx context.Context) (string, bool)

GetRequestID retrieves the request ID from the context.

func GetTenant

func GetTenant(ctx context.Context) (any, bool)

GetTenant retrieves the tenant value from the context.

func GetTenantID added in v0.96.0

func GetTenantID(ctx context.Context) string

GetTenantID returns the tenant ID SetTenantID stored, or "" when none was.

func GetUser

func GetUser(ctx context.Context) (any, bool)

GetUser retrieves the user value from the context.

func HandlerAdapter

func HandlerAdapter[I, O any](h Handler[I, O]) http.HandlerFunc

HandlerAdapter bridges a typed Handler into a standard http.HandlerFunc. It handles input binding, panic recovery, and output serialization.

func IsCrossSiteRequest added in v0.85.0

func IsCrossSiteRequest(r *http.Request) bool

IsCrossSiteRequest reports whether the request came from another origin, using Fetch Metadata first and the Origin header as the fallback. This is the ONE implementation of the repo's cross-site form guard; every battery and tool that refuses cross-site POSTs calls it and keeps only its own response shape.

Sec-Fetch-Site is authoritative where the browser sends it: "same-origin" and "none" (a user-initiated navigation: address bar, bookmark) are safe, "cross-site" is refused outright. "same-site" is NOT proof of same-origin: the site computation drops the port and folds sibling subdomains, so a page on evil.example.com (or on a sibling port of a localhost tool) is same-site while its Origin names a different origin, and a SameSite cookie still attaches. It falls through to the Origin-host comparison, as does any unknown value, and under "same-site" an absent, opaque or unparseable Origin is cross-site: a same-origin page says "same-origin", so only a sibling (one sending Referrer-Policy: no-referrer, which makes Origin "null") reaches that arm. Two copies of this guard (battery/setup, kiln/chat) trusted "same-site" and were driven to an attacker-owned admin account and an approved destructive plan by the 2026-09-06 probes; the copies are gone.

Without Fetch Metadata, an Origin whose host differs from r.Host is cross-site. An absent or opaque ("null") Origin cannot prove an attack and is allowed: a legitimate top-level same-origin form navigation sends Origin: null too, and non-browser clients (curl, tests, native apps) send neither header.

func IsCrossSiteRequestStrict added in v0.85.0

func IsCrossSiteRequestStrict(r *http.Request) bool

IsCrossSiteRequestStrict is IsCrossSiteRequest for surfaces that are only ever called by fetch(), never by a form navigation: there an opaque "Origin: null" with no Fetch Metadata to vouch for it is the sandboxed-iframe / cross-origin-redirect shape, not a legitimate top-level navigation, so it is refused too. Where the browser sends Sec-Fetch-Site the header decides exactly as in IsCrossSiteRequest.

func IsForgeableRequest added in v0.85.0

func IsForgeableRequest(r *http.Request) bool

IsForgeableRequest reports whether a cross-site page could have sent this request WITHOUT a CORS preflight: the CORS "simple request" content types plus the absent header (a bodyless fetch() sends no Content-Type). application/json and every other type are not forgeable: a cross-site POST carrying one is preflighted.

func IsForgedCrossSiteWrite added in v0.96.0

func IsForgedCrossSiteWrite(r *http.Request) bool

IsForgedCrossSiteWrite reports whether r is a write another site's page could have sent with the user's cookies and no CORS preflight: any method but GET, HEAD and OPTIONS (a CORS preflight is an OPTIONS request with no body and no content type, and must reach CORS), a forgeable content type (IsForgeableRequest), and a cross-site origin (IsCrossSiteRequest). It is the check entity endpoints and CRUD routes run before their handler; RefuseCrossSite applies it to any handler.

func IsJSONContentType added in v0.86.0

func IsJSONContentType(ct string) bool

IsJSONContentType reports whether ct names the JSON media type. It parses the value through mime.ParseMediaType so "application/json" and "application/json; charset=utf-8" both match, while a literal prefix check would also accept "application/jsonp" or "application/json-evil", a known Content-Type smuggling trick.

Exported for battery/auth's JSON gate (json_limit.go), which previously carried its own hand-rolled copy: the shared version accepts any "+json" structured-syntax suffix (not only application/*+json) and rejects values whose parameters fail to parse, where the old copy ignored parameter syntax entirely.

func IsSafeRelativePath added in v0.86.0

func IsSafeRelativePath(p string) bool

IsSafeRelativePath reports whether p is a same-origin, path-only redirect target: it must start with a single "/", carry no scheme or host (also after percent-decoding, where "/%2Fevil" would become "//evil"), and contain none of backslash, NUL, CR, LF, or TAB in either the raw or the decoded form, since browsers decode before they navigate and any of those bytes changes what the redirect means.

It is the one implementation of the check formerly duplicated as battery/auth's isSafeRelativePath (the "next" / return-to form field) and framework/uihost's isSafePartialRedirect (the X-Gofastr-Location partial-redirect header).

func JSONErrorDetail added in v0.96.0

func JSONErrorDetail(err error) error

JSONErrorDetail turns an error from decoding a JSON body into the phrase a client reads: the JSON path in the client's own key spelling and the JSON type expected there, the byte a syntax error sits at, an empty or cut-off body. It never carries the decoder's own text, which names Go types and struct names ("cannot unmarshal number into batchDeleteRequest.ids.0 of type string", "Go value of type map[string]interface {}"): those are the server's internals, not the client's mistake. An error it does not recognise reads as a generic "the value could not be decoded".

The result is an i18n.Phrased error with no wrapped cause: pass it as the {{detail}} param of a frame (ReasonInvalidJSON, a package's own "invalid request body: {{detail}}") and it translates with the frame while the frame's code stays the body's "reason". Its Error() is the English text, for a protocol (JSON-RPC) whose messages are English. A i18n.Phrased err (a strict-key refusal) comes back as its own phrase; a nil err is nil.

func JSONTrailingData added in v0.96.0

func JSONTrailingData() error

JSONTrailingData is the JSONErrorDetail for a body holding more after its one JSON value, for a decode site with its own trailing check.

func RecheckPrincipal added in v0.87.0

func RecheckPrincipal(ctx context.Context) (context.Context, bool)

RecheckPrincipal runs the installed re-validation against ctx and returns the refreshed context and true, or (ctx, false) when the principal no longer holds. With nothing installed it returns (ctx, true): an anonymous request, or one authenticated by middleware that installs no check, has nothing to re-validate, which is the behaviour every request had before this seam.

The refreshed context keeps the same checks installed, so it can itself be re-checked later.

func RefuseCrossSite added in v0.96.0

func RefuseCrossSite(h http.Handler) http.Handler

RefuseCrossSite wraps h so a forged cross-site write (IsForgedCrossSiteWrite) gets 403 with ReasonCrossSiteRefused before h runs. It needs no token, so it suits a plain server-rendered form route mounted with Router().HandleFunc, which the CSRF middleware's per-form token does not reach. Requests from the app's own pages, non-browser clients (no Origin, no Sec-Fetch-Site) and preflighted requests (application/json and other non-simple types) pass. A route that must accept a cross-site POST, such as an OAuth form_post callback, is simply not wrapped.

func RequestFromContext

func RequestFromContext(ctx context.Context) (*http.Request, bool)

RequestFromContext retrieves the *http.Request stored by HandlerAdapter.

func Respond

func Respond(w http.ResponseWriter, r *http.Request, out any)

Respond writes out the response based on out's type:

  • nil, or a nil pointer → 204 No Content (a nil slice or map is still JSON "null", 200: a list handler's empty result is content)
  • ResponseType → delegates to the custom type
  • any other value → JSON serialization, 200 OK

func SetLogger

func SetLogger(ctx context.Context, logger any) context.Context

SetLogger stores a logger in the context.

func SetRequestID

func SetRequestID(ctx context.Context, id string) context.Context

SetRequestID stores a request ID in the context.

func SetTenant

func SetTenant(ctx context.Context, tenant any) context.Context

SetTenant stores a tenant value in the context.

func SetTenantID added in v0.96.0

func SetTenantID(ctx context.Context, id string) context.Context

SetTenantID stores the resolved tenant ID string in the context: the slot framework/tenant's SetTenantID and GetTenantID read and write, and the one tenant scoping filters on. It lives here, below framework/tenant, so a leaf package (framework/access's cached role resolver) can partition by tenant without importing the tenant package.

func SetUser

func SetUser(ctx context.Context, user any) context.Context

SetUser stores a user value in the context. It also drops any principal re-check installed for the previous user (see WithPrincipalCheck): the middleware that sets a user installs its own check after this call.

func ToolAdapter added in v0.93.0

func ToolAdapter[I, O any](h Handler[I, O]) func(ctx context.Context, params map[string]any) (any, error)

ToolAdapter bridges a typed Handler into an MCP tool handler, the signature core/mcp's ToolHandler names, so one Handler serves both an HTTP route (HandlerAdapter) and its MCP twin.

The tool's params use the names the route binds. A field tagged `query:"q"` or `path:"id"` reads params["q"] or params["id"], and the remaining params decode strictly, the way Bind decodes a JSON body: an unknown or duplicate key is an error. As in Bind, the body applies last. A path value no route could match (a "/", a ".." segment, a control byte) is refused, where the router would have cut it short. No two keys anywhere in params may fold to one name ("q" and "Q"), and a tagged param given as "" is absent unless its tag carries `,empty`, the rules Bind applies to a query string.

The handler's context carries no *http.Request: RequestFromContext returns nil on this path. Header-tagged fields stay zero.

func UnmarshalStrict added in v0.82.0

func UnmarshalStrict(data []byte, dst any, opts ...StrictOption) error

UnmarshalStrict is DecodeStrict over bytes already in memory (a websocket frame, a buffered body).

func ViolationBodies added in v0.94.0

func ViolationBodies(ctx context.Context, fields map[string][]schema.Violation) (messages, codes map[string][]string)

ViolationBodies renders field violations for a response body: the messages in ctx's locale, and the stable codes in the same order.

func WithPrincipalCheck added in v0.87.0

func WithPrincipalCheck(ctx context.Context, check PrincipalCheck) context.Context

WithPrincipalCheck installs check as the request's identity re-validation. Identity middleware calls it right after SetUser: the middleware that established the principal is the one that knows how to re-establish it.

A request handler reads the principal once, from the context it was handed. That is right for a request that answers and ends; it is wrong for one that stays open, an SSE stream or a long poll, because the context then outlives the authority that built it. Such a handler calls RecheckPrincipal on each delivery and closes when it fails.

SetUser clears whatever check was installed, so a check never outlives the identity it was built for: a later middleware that switches or clears the user must not leave the earlier principal's check behind to resurrect it. Pass a nil check to install "nothing to re-check" explicitly.

func WriteError

func WriteError(w http.ResponseWriter, r *http.Request, err error)

WriteError writes a structured error response to w, translated into r's locale, in the one error envelope every route answers with:

{"error": "<message>", "code": 400, "reason": "<stable code>",
 "fields": {...}, "field_reasons": {...}, "success": false}

"reason" is absent for an error with no Reason (Errorf's own wording), and "fields"/"field_reasons" for an error with no Violations.

An *Error is rendered as the developer built it: its Code, its Reason (translated, with the code in "reason") or its Message, and its Violations. Any other error type is treated as an internal failure: the response message is a generic "internal server error" with status 500, and the original error stays out of the response body. Callers that *do* want a message to reach the client must wrap explicitly via Fail, WrapError or Errorf (WrapError keeps the cause for `errors.Is/As` without leaking it).

This prevents accidental leaks of database errors, driver-specific strings ("pq: password authentication failed for user \"admin\""), or wrapped stack traces. r may be nil; the message is then English.

A nil *Error is the generic 500, and a Code outside 400-599 (a forgotten zero, a 1xx or 2xx) is sent as 500: an error never goes out under a status that reads as success.

Types

type Error

type Error struct {
	Code       int                           // HTTP status code
	Reason     i18n.Phrase                   // translated message + stable code (optional)
	Params     map[string]any                // Reason's {{placeholders}}
	Message    string                        // message sent as written when Reason is zero
	Err        error                         // wrapped cause (optional)
	Violations map[string][]schema.Violation // field-level validation errors (optional)
}

Error is a structured HTTP error with optional field-level validation errors.

Reason, when set, is what the client reads: the response message is Reason translated into the request's locale with Params, and the body's "reason" is Reason.Code(), the stable code a client branches on. Message is the text an error with no Reason sends as written (an app's own wording); framework code always sets a Reason.

func Errorf

func Errorf(code int, format string, args ...any) *Error

Errorf creates a new Error with the given HTTP status code and a formatted message sent as written. Framework code uses Fail so the message translates; Errorf is for an app's own wording.

func Fail added in v0.94.0

func Fail(code int, reason i18n.Phrase, params ...map[string]any) *Error

Fail creates an Error whose message is reason translated with params.

func ValidationError

func ValidationError(fields map[string][]schema.Violation) *Error

ValidationError creates a 400 error with field-level violations.

func WrapError

func WrapError(code int, reason i18n.Phrase, err error) *Error

WrapError wraps an existing error with an HTTP status code and the phrase the client reads, keeping the cause for errors.Is/As without sending it.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface with the English message.

func (*Error) Phrased added in v0.94.0

func (e *Error) Phrased() (i18n.Phrase, map[string]any)

Phrased implements i18n.Phrased; an Error with no Reason returns the zero Phrase, which i18n.ErrorText passes over.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the wrapped cause for errors.Is/As.

type HTML

type HTML string

HTML is a response type that writes text/html.

func (HTML) ContentType

func (h HTML) ContentType() string

func (HTML) WriteBody

func (h HTML) WriteBody(w http.ResponseWriter) error

type Handler

type Handler[I, O any] func(ctx context.Context, in I) (O, error)

Handler is a typed function that processes an input of type I and returns an output of type O. It receives a context (which carries the *http.Request via RequestFromContext) and a fully-bound input struct.

type PrincipalCheck added in v0.87.0

type PrincipalCheck func(ctx context.Context) (context.Context, bool)

PrincipalCheck re-validates a long-lived request's principal against live state. It receives the context to refresh and returns the context a fresh request with the same credentials would carry, or false when that request would be refused or would arrive anonymous: the session was revoked, the token expired, the owning user was deleted.

type RawBytes

type RawBytes struct {
	Data []byte
	CT   string // content type, e.g. "image/png"
}

RawBytes is a response type for raw bytes with an explicit content type.

func (RawBytes) ContentType

func (r RawBytes) ContentType() string

func (RawBytes) WriteBody

func (r RawBytes) WriteBody(w http.ResponseWriter) error

type ResponseType

type ResponseType interface {
	// ContentType returns the MIME type for the response.
	ContentType() string
	// WriteBody writes the response body to w.
	WriteBody(w http.ResponseWriter) error
}

ResponseType is an interface for custom response types that control how they are serialized and what Content-Type is used.

type SSE

type SSE struct {
	Event string
	Data  string
	ID    string // optional event ID
}

SSE is a Server-Sent Event response.

func (SSE) ContentType

func (s SSE) ContentType() string

func (SSE) WriteBody

func (s SSE) WriteBody(w http.ResponseWriter) error

type StrictOption added in v0.96.0

type StrictOption func(*strictConfig)

StrictOption adjusts how UnmarshalStrict decodes a body that passed the key checks. The key rules themselves never change.

func UseNumber added in v0.96.0

func UseNumber() StrictOption

UseNumber decodes numbers held in interface values (any, map[string]any, []any) as json.Number instead of float64, so an integer past 2^53 keeps its exact digits. Typed numeric fields are unaffected.

Jump to

Keyboard shortcuts

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