httpx

package
v0.4.20 Latest Latest
Warning

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

Go to latest
Published: Sep 17, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

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

View Source
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

View Source
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

func DecodeStrictJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, target any) error

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

func Limit(r *http.Request, def, max int) int

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

func NoStore(next http.Handler) http.Handler

NoStore prevents browsers and intermediaries from retaining a response. Mount it on authenticated or otherwise request-specific route groups.

func ParseStrictLimit

func ParseStrictLimit(raw string, defaultLimit, maxLimit int) (int, error)

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

func ParseStrictQuery(raw string, accepted ...string) (url.Values, error)

ParseStrictQuery decodes a raw query and rejects unknown or repeated keys. accepted lists the complete query contract for the endpoint.

func UnmarshalStrict

func UnmarshalStrict(data []byte, target any) error

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

func ParseIDCursor(r *http.Request) IDCursor

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.

func (IDCursor) String

func (c IDCursor) String() string

String encodes the cursor for the wire. The zero cursor encodes as "", so a caller can hand it straight to NextCursor without a special case.

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

func Cap[T any](items []T, maxBytes int, cursorOf func(T) string) Page[T]

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.

func NewPage

func NewPage[T any](items []T, more bool, cursorOf func(T) string) Page[T]

NewPage builds a page from rows already trimmed to the requested limit, deriving NextCursor from the last row. cursorOf returns the cursor for a row; pass nil when the caller knows there are no further pages.

func (Page[T]) Truncate

func (p Page[T]) Truncate(note string) Page[T]

Truncate marks the page as cut and records why. Returns the page so it can be used inline.

type TimeIDCursor

type TimeIDCursor struct {
	Time time.Time
	ID   string
}

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 "".

Jump to

Keyboard shortcuts

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