// Package httputil holds the small set of request/response helpers shared
// across HTTP handlers in lethe: JSON read with body cap and strict
// decoding, JSON write, and an iterator-based NDJSON line reader for the
// ingest endpoint.
//
// The helpers return culpa-coded errors so the apierror.Render boundary can
// translate them into RFC 7807 responses without losing the cause.
package httputil
import (
"bufio"
"encoding/json"
"errors"
"io"
"iter"
"net/http"
"go.bigb.es/auxilia/culpa"
)
// ReadJSON limits the request body to maxBytes, decodes it into dst with
// DisallowUnknownFields, and rejects trailing tokens. Body-cap exceedance
// is reported with code TOO_LARGE; any other parse failure is INVALID.
func ReadJSON[T any](r *http.Request, dst *T, maxBytes int64) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return culpa.WithCode(culpa.Wrap(err, "request body exceeds limit"), "TOO_LARGE")
}
return culpa.WithCode(culpa.Wrap(err, "decode json body"), "INVALID")
}
// Reject trailing data after the first JSON value.
if dec.More() {
return culpa.WithCode(culpa.New("unexpected data after JSON body"), "INVALID")
}
return nil
}
// WriteJSON encodes v as JSON with the given status code and the standard
// application/json content type. Encoder errors are returned so the caller
// can decide how to surface them; in practice they only fire on a dropped
// client connection.
func WriteJSON(w http.ResponseWriter, status int, v any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(v)
}
// ReadNDJSONLines streams NDJSON lines from r, yielding the raw bytes of
// each non-empty line. The yielded slice is owned by an internal buffer
// and is reused on the next iteration: consumers that retain a line must
// copy it themselves.
//
// Body-cap exceedance and oversized single lines surface as a (nil, err)
// terminal yield with code TOO_LARGE; other scanner failures use INVALID.
func ReadNDJSONLines(r io.Reader, maxBytes int64) iter.Seq2[[]byte, error] {
return func(yield func([]byte, error) bool) {
limited := io.LimitReader(r, maxBytes+1)
sc := bufio.NewScanner(limited)
// Allow the scanner to grow the token buffer up to the body cap.
// The starting buffer of 64 KiB is plenty for typical lines.
sc.Buffer(make([]byte, 0, 64*1024), int(maxBytes)+1)
var read int64
for sc.Scan() {
line := sc.Bytes()
read += int64(len(line)) + 1 // +1 for the consumed newline
if read > maxBytes {
yield(nil, culpa.WithCode(culpa.New("ndjson body exceeds limit"), "TOO_LARGE"))
return
}
if len(line) == 0 {
continue
}
if !yield(line, nil) {
return
}
}
if err := sc.Err(); err != nil {
if errors.Is(err, bufio.ErrTooLong) {
yield(nil, culpa.WithCode(culpa.Wrap(err, "ndjson line too long"), "TOO_LARGE"))
return
}
yield(nil, culpa.WithCode(culpa.Wrap(err, "scan ndjson"), "INVALID"))
}
}
}