Documentation
¶
Overview ¶
Package httpx is the module SDK's one shape for a paginated list response, and the one way to read a page request.
🔴 WHY THIS EXISTS. Across the shipped modules there were three pagination dialects and two byte-identical cursor functions written independently in different repos — `auditCursor` in credit and `roleAuditCursor` in users-roles are the same function, character for character, and user-core runs a third scheme on top. Nothing was wrong with any one of them; there was simply no owner for the convention, so every module invented it again. This package is that owner.
Keyset always, offset never ¶
There is no Offset here and there will not be one. Offset pagination is wrong under concurrent writes in a way that is invisible in testing: a row inserted before the cursor position shifts every later row down, so page 2 re-shows a row from page 1, and a delete makes page 2 skip one entirely. Audit logs and ledgers — which is most of what modules paginate — are exactly the append-heavy tables where that happens constantly. A keyset cursor names the last row you saw, so concurrent writes cannot move it.
The cursor is opaque ¶
It is base64url over an internal form, deliberately. A cursor a client can read is a cursor a client will parse, and then the encoding cannot change without breaking them. Clients get it from NextCursor and hand it back.
Index ¶
- Constants
- Variables
- func DecodeStrictJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, target any) error
- func Limit(r *http.Request, def, max int) int
- func NoStore(next http.Handler) http.Handler
- func ParseStrictLimit(raw string, defaultLimit, maxLimit int) (int, error)
- func ParseStrictQuery(raw string, accepted ...string) (url.Values, error)
- func UnmarshalStrict(data []byte, target any) error
- func ValidStaticModuleRoute(raw string, surface ModuleSurface) bool
- type CursorScope
- type IDCursor
- type ModuleSurface
- type Page
- type TimeIDCursor
Constants ¶
const DefaultMaxBytes = 16 << 10
DefaultMaxBytes is the size a tool result should aim to stay under.
It is a guideline, not a limit the SDK enforces: the platform caps a tool result at 32 KB when it stores one, and an agent's context is the real scarce resource. 16 KB leaves room for the envelope and for the rest of the conversation.
Variables ¶
var ( // ErrInvalidQuery means a strict query was malformed, contained an // undeclared key, or repeated a key. ErrInvalidQuery = errors.New("httpx: invalid query") // ErrInvalidLimit means an explicit strict page limit was malformed or // outside the configured bounds. ErrInvalidLimit = errors.New("httpx: invalid limit") // ErrInvalidCursor means a scoped cursor was malformed, stale for another // filter scope, or did not name a valid time-and-ID position. ErrInvalidCursor = errors.New("httpx: invalid cursor") )
Functions ¶
func DecodeStrictJSON ¶
DecodeStrictJSON decodes one size-bounded HTTP request body into target. Unknown object fields and every second or trailing JSON value are rejected. maxBytes must be positive; an oversized body returns *http.MaxBytesError.
The caller still owns endpoint-specific validation and error mapping. This helper owns only the repeated transport boundary.
func Limit ¶
Limit reads ?limit=, clamped to [1, max] and defaulting to def.
A malformed value falls back to def rather than erroring: a page size is a hint, and failing a read over one is a worse trade than serving the default.
func NoStore ¶
NoStore prevents browsers and intermediaries from retaining a response. Mount it on authenticated or otherwise request-specific route groups.
func ParseStrictLimit ¶
ParseStrictLimit validates a caller-provided page size. Empty raw uses defaultLimit; explicit values must be within [1, maxLimit]. It panics for an invalid server-side configuration because that is a startup/programming bug, not a request error.
func ParseStrictQuery ¶
ParseStrictQuery decodes a raw query and rejects unknown or repeated keys. accepted lists the complete query contract for the endpoint.
func UnmarshalStrict ¶
UnmarshalStrict decodes exactly one JSON value into target. Object fields not declared by a struct target and any second/trailing JSON value are rejected.
Use this for module-owned contracts whose shape must not silently drift, such as contribution payloads and bounded inter-module responses. It is intentionally opt-in; ordinary encoding/json compatibility remains useful for forward-compatible platform envelopes.
func ValidStaticModuleRoute ¶
func ValidStaticModuleRoute(raw string, surface ModuleSurface) bool
ValidStaticModuleRoute reports whether raw is one concrete, module-relative route under surface, such as "/internal/profile".
Contribution payloads carry callable endpoints, not router declarations, so roots, trailing slashes, parameters/templates, queries, fragments, escaped bytes, backslashes, and dot traversal are all rejected. The 2 KiB bound matches the contract boundary before the value is persisted.
Types ¶
type CursorScope ¶
type CursorScope struct {
// contains filtered or unexported fields
}
CursorScope is the opaque identity of one paginated resource and its normalized filters. Cursors are accepted only inside the scope that created them, preventing a boundary from one filtered list being replayed against another.
func NewCursorScope ¶
func NewCursorScope(resource string, normalizedFilters ...string) CursorScope
NewCursorScope builds an unambiguous scope for resource and its normalized filters. Filter values are length-prefixed so distinct tuples cannot collide. resource must be a stable, non-empty endpoint/resource name.
type IDCursor ¶
type IDCursor int64
IDCursor is a keyset position on a single monotonically increasing integer key — a bigserial audit id, a ledger sequence. Zero means "from the start", which is what an absent cursor decodes to.
func ParseIDCursor ¶
ParseIDCursor reads ?cursor= as an IDCursor.
A cursor that will not decode returns 0 — the first page — rather than an error. It is the same answer the client gets for no cursor at all, it cannot leak whether some other row exists, and a stale or truncated cursor from an old client degrades to "start over" instead of a 400 nobody can act on.
type ModuleSurface ¶
type ModuleSurface string
ModuleSurface names one SDK-authenticated HTTP surface.
const ( // InternalSurface is the actorless service-to-service module surface. InternalSurface ModuleSurface = "internal" // PlatformSurface is the operator-authenticated module surface. PlatformSurface ModuleSurface = "platform" // PublicSurface is the module surface reachable without an operator role. PublicSurface ModuleSurface = "public" )
type Page ¶
type Page[T any] struct { // Items is never null on the wire: an empty page marshals as [], because a // null that a client must special-case is a bug waiting to be written. Items []T `json:"items"` // NextCursor is fed back as ?cursor= to fetch the next page. nil means this // is the last page. NextCursor *string `json:"nextCursor,omitempty"` // Truncated marks a page whose ITEMS were cut — a single row too large to // return whole, or a result set clipped by a hard cap. It is not the same // as "there are more pages"; that is NextCursor. Both can be true. Truncated bool `json:"truncated,omitempty"` // Note explains a Truncated page in one plain sentence, for a human or a // model reading the response. Empty otherwise. Note string `json:"note,omitempty"` }
Page is the response shape for every paginated list a module serves.
NextCursor is nil — not empty string — when there are no further rows, so "no more pages" and "a page boundary that happens to encode as empty" cannot be confused. Clients test for null.
func Cap ¶
Cap returns at most as many leading items as fit within maxBytes once marshalled, marking the page Truncated when it had to drop any.
🔴 IT DROPS WHOLE ITEMS, NEVER BYTES. The obvious way to bound a result is to marshal it and cut the string, and that produces INVALID JSON — the reader gets a parse error instead of data, or worse, a half-written object it silently mis-reads. Dropping whole items keeps the result parseable at every size, which is what makes truncation something a caller can reason about rather than something that breaks them.
The page it returns is self-describing: Truncated says it happened, Note says how many were dropped and how to get the rest, and NextCursor (when the caller supplies cursorOf) is the position to resume from. A model reading it can ask for the next page instead of concluding the list is short.
Binary does not belong here at all. A tool returning bytes should return a presigned URL (storage.PresignGet) and let the caller fetch it out of band — base64 in a tool result spends the agent's context on data it cannot read.
type TimeIDCursor ¶
TimeIDCursor is a keyset position on (timestamp, id) — the ordering almost every "newest first" list actually uses.
The ID half is not optional padding: timestamps collide, and two rows written in the same microsecond would otherwise make a page boundary ambiguous, so a row is either shown twice or skipped. The tiebreak is what makes the keyset total.
func ParseScopedTimeIDCursor ¶
func ParseScopedTimeIDCursor(raw string, scope CursorScope) (TimeIDCursor, error)
ParseScopedTimeIDCursor decodes a cursor for scope. Empty raw means the first page. Cursors carry a cs1. version prefix. All malformed, oversized, unknown-version, or cross-scope values return ErrInvalidCursor.
func ParseTimeIDCursor ¶
func ParseTimeIDCursor(r *http.Request) TimeIDCursor
ParseTimeIDCursor reads ?cursor= as a TimeIDCursor. Same undecodable-means-first-page rule as ParseIDCursor.
func (TimeIDCursor) IsZero ¶
func (c TimeIDCursor) IsZero() bool
IsZero reports the "from the start" cursor.
func (TimeIDCursor) ScopedString ¶
func (c TimeIDCursor) ScopedString(scope CursorScope) string
ScopedString encodes the cursor with its filter scope. The zero cursor encodes as "", matching TimeIDCursor.String.
func (TimeIDCursor) String ¶
func (c TimeIDCursor) String() string
String encodes the cursor for the wire; the zero cursor encodes as "".