// 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")) } } }