~bigbes/lethe

ref: 6806748f7302141c5259dd82474f3655ccc231c8 lethe/internal/pkg/httputil/httputil.go -rw-r--r-- 3.1 KiB
6806748f — Eugene Blikh chore: track task file and dist update for search UI 23 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 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"))
		}
	}
}