// Package httpx holds small HTTP helpers shared by feature packages without // creating an import cycle (feature modules import httpx; httpx imports // nothing of ours). package httpx import ( "encoding/json" "errors" "fmt" "io" "net/http" ) // maxRequestBody caps decoded JSON bodies to protect against memory abuse. const maxRequestBody = 1 << 20 // 1 MiB // WriteJSON encodes v as JSON with the given status. func WriteJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if v == nil { return } _ = json.NewEncoder(w).Encode(v) } // WriteError writes a JSON error envelope. func WriteError(w http.ResponseWriter, status int, message string) { WriteJSON(w, status, map[string]string{"error": message}) } // DecodeJSON reads a JSON request body into dst. // // It rejects unknown fields and non-object top-level values, and refuses // bodies larger than 1 MiB. Error messages are safe to return to clients. func DecodeJSON(r *http.Request, dst any) error { if ct := r.Header.Get("Content-Type"); ct != "" { if mediaType := contentTypeOnly(ct); mediaType != "application/json" { return fmt.Errorf("expected Content-Type application/json, got %q", ct) } } dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBody)) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { var syntaxErr *json.SyntaxError var typeErr *json.UnmarshalTypeError switch { case errors.As(err, &syntaxErr): return fmt.Errorf("malformed JSON at byte %d", syntaxErr.Offset) case errors.As(err, &typeErr): return fmt.Errorf("field %q has the wrong type", typeErr.Field) case errors.Is(err, io.EOF): return errors.New("request body is empty") case errors.Is(err, io.ErrUnexpectedEOF): return errors.New("request body is truncated") default: return err } } // Reject trailing garbage after the first JSON value. if dec.More() { return errors.New("request body contains more than one JSON value") } return nil } // contentTypeOnly strips parameters from a Content-Type header, e.g. // "application/json; charset=utf-8" -> "application/json". func contentTypeOnly(ct string) string { for i := 0; i < len(ct); i++ { if ct[i] == ';' { return trimSpace(ct[:i]) } } return trimSpace(ct) } func trimSpace(s string) string { start, end := 0, len(s) for start < end && (s[start] == ' ' || s[start] == '\t') { start++ } for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { end-- } return s[start:end] }