Files
web/httputil/bind.go
T

326 lines
10 KiB
Go
Raw Permalink Normal View History

package httputil
import (
"context"
"encoding"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/core/valid"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// Bind adapts a typed business function whose request is assembled from more than
// the JSON body. Each field of Req declares its source with a struct tag:
//
// type updateRoleRequest struct {
// RoleID uuid.UUID `path:"roleID" validate:"required"`
// Page int `query:"page" default:"1" validate:"min=1"`
// Name string `json:"name" validate:"omitempty,max=200"`
// }
//
// - path: fills from a chi route parameter (r.Context()).
// - query: fills from the URL query string; a repeated parameter binds to a slice.
// - json: fills from the JSON body (standard encoding/json).
//
// It then validates the assembled struct once with v and calls fn — the handler
// signature is identical to [Handle]. On success Res is encoded as JSON (200 by
// default, or the [WithStatus] code). On error it flows through [Error].
//
// Conversion covers string, the sized integer/unsigned/float types, bool, and any
// type whose pointer implements [encoding.TextUnmarshaler] (so uuid.UUID and
// time.Time bind with no special-casing). A value that fails to convert is
// reported as [xerrors.ErrInvalidInput] naming the parameter — a 400, never a 500.
//
// The struct is reflected over once per type at wiring time and the result cached.
// A field declaring more than one source tag, an unsupported field type, or a
// default: that is not a valid value for its field all panic at wiring, so a
// mis-tagged struct fails the service at boot rather than on a request.
func Bind[Req, Res any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) (Res, error), opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusOK, opts)
plan := planFor(reflect.TypeOf((*Req)(nil)).Elem())
return func(w http.ResponseWriter, r *http.Request) {
var req Req
if err := bindRequest(plan, v, r, &req); err != nil {
Error(logger, w, r, err)
return
}
res, err := fn(r.Context(), req)
if err != nil {
Error(logger, w, r, err)
return
}
JSON(w, status, res)
}
}
// BindEmpty is [Bind] for a function that returns no response body. Req is filled
// from path, query and body exactly as in Bind; on success a body-less status is
// written (204 by default, or the [WithStatus] code).
//
// Unlike [HandleEmpty] it does not require a request body: a bodiless request
// (GET, DELETE, Content-Length: 0) is not an error, so a DELETE /resource/{id}
// with a path: tag binds directly instead of failing on io.EOF.
func BindEmpty[Req any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) error, opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusNoContent, opts)
plan := planFor(reflect.TypeOf((*Req)(nil)).Elem())
return func(w http.ResponseWriter, r *http.Request) {
var req Req
if err := bindRequest(plan, v, r, &req); err != nil {
Error(logger, w, r, err)
return
}
if err := fn(r.Context(), req); err != nil {
Error(logger, w, r, err)
return
}
w.WriteHeader(status)
}
}
// bindRequest decodes the body (when present), overlays path/query fields, and
// validates the assembled struct once. dst must be a pointer to Req.
func bindRequest(plan *bindPlan, v valid.Validator, r *http.Request, dst any) error {
// A body is optional: an empty body decodes to io.EOF, which we treat as
// "no body" rather than an error (retires the HandleEmpty bodiless trap).
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil && !errors.Is(err, io.EOF) {
return xerrors.New(xerrors.ErrInvalidInput, "invalid JSON: "+err.Error())
}
}
if err := plan.apply(r, reflect.ValueOf(dst).Elem()); err != nil {
return err
}
if err := v.Struct(reflect.ValueOf(dst).Elem().Interface()); err != nil {
return err
}
return nil
}
// --- binding plan (reflected once per type, cached) ---
type sourceKind uint8
const (
sourcePath sourceKind = iota
sourceQuery
)
// fieldBind describes how one path/query field is filled. json/untagged fields
// are handled by the body decoder and never appear here.
type fieldBind struct {
index int
name string
source sourceKind
isSlice bool
hasDefault bool
defaultVal string
}
type bindPlan struct {
fields []fieldBind
}
var planCache sync.Map // reflect.Type -> *bindPlan
// planFor returns the cached plan for t, building (and validating) it once. It
// panics on a mis-tagged struct, so callers reach it at wiring time and the
// service fails to boot rather than at request time.
func planFor(t reflect.Type) *bindPlan {
if cached, ok := planCache.Load(t); ok {
return cached.(*bindPlan)
}
p := buildPlan(t)
actual, _ := planCache.LoadOrStore(t, p)
return actual.(*bindPlan)
}
func buildPlan(t reflect.Type) *bindPlan {
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("httputil.Bind: Req must be a struct, got %s", t))
}
p := &bindPlan{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
pathTag, hasPath := f.Tag.Lookup("path")
queryTag, hasQuery := f.Tag.Lookup("query")
_, hasJSON := f.Tag.Lookup("json")
n := 0
for _, ok := range []bool{hasPath, hasQuery, hasJSON} {
if ok {
n++
}
}
if n > 1 {
panic(fmt.Sprintf("httputil.Bind: field %s.%s declares more than one source tag (path/query/json); a field binds from exactly one channel", t.Name(), f.Name))
}
if !hasPath && !hasQuery {
continue // json or untagged — the body decoder owns it
}
fb := fieldBind{index: i}
if hasPath {
fb.source, fb.name = sourcePath, pathTag
} else {
fb.source, fb.name = sourceQuery, queryTag
}
ft := f.Type
fb.isSlice = ft.Kind() == reflect.Slice && !implementsTextUnmarshaler(ft)
if fb.isSlice && fb.source == sourcePath {
panic(fmt.Sprintf("httputil.Bind: field %s.%s is a path parameter and cannot be a slice", t.Name(), f.Name))
}
elem := ft
if fb.isSlice {
elem = ft.Elem()
}
if !convertible(elem) {
panic(fmt.Sprintf("httputil.Bind: field %s.%s has unsupported type %s (want string, integer, float, bool, or encoding.TextUnmarshaler)", t.Name(), f.Name, ft))
}
if dv, ok := f.Tag.Lookup("default"); ok {
fb.hasDefault, fb.defaultVal = true, dv
// A default that cannot convert is a wiring mistake — fail at boot.
if err := setScalar(reflect.New(elem).Elem(), dv); err != nil {
panic(fmt.Sprintf("httputil.Bind: field %s.%s default %q is not a valid %s: %v", t.Name(), f.Name, dv, elem, err))
}
}
p.fields = append(p.fields, fb)
}
return p
}
// apply overlays the path/query fields onto an already body-decoded struct value.
func (p *bindPlan) apply(r *http.Request, sv reflect.Value) error {
var query map[string][]string
for _, fb := range p.fields {
var raw []string
present := false
switch fb.source {
case sourcePath:
if v := chi.URLParamFromCtx(r.Context(), fb.name); v != "" {
raw, present = []string{v}, true
}
case sourceQuery:
if query == nil {
query = r.URL.Query()
}
if vs, ok := query[fb.name]; ok {
raw, present = vs, true
}
}
field := sv.Field(fb.index)
if !present {
// Absent: apply the default if declared, else leave the zero value.
// A present-but-empty value (?q=) is *not* absent and skips this.
if fb.hasDefault {
if err := setScalar(field, fb.defaultVal); err != nil {
return xerrors.New(xerrors.ErrInvalidInput, fmt.Sprintf("invalid %s: %v", fb.name, err))
}
}
continue
}
if fb.isSlice {
slice := reflect.MakeSlice(field.Type(), len(raw), len(raw))
for i, s := range raw {
if err := setScalar(slice.Index(i), s); err != nil {
return xerrors.New(xerrors.ErrInvalidInput, fmt.Sprintf("invalid %s: %v", fb.name, err))
}
}
field.Set(slice)
continue
}
if err := setScalar(field, raw[0]); err != nil {
return xerrors.New(xerrors.ErrInvalidInput, fmt.Sprintf("invalid %s: %v", fb.name, err))
}
}
return nil
}
// --- conversion ---
var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
func implementsTextUnmarshaler(t reflect.Type) bool {
return reflect.PointerTo(t).Implements(textUnmarshalerType)
}
// convertible reports whether a single value of type t can be set from a string.
func convertible(t reflect.Type) bool {
if implementsTextUnmarshaler(t) {
return true
}
switch t.Kind() {
case reflect.String,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64,
reflect.Bool:
return true
default:
return false
}
}
// setScalar sets one addressable value from its string form. TextUnmarshaler is
// preferred so uuid.UUID / time.Time bind through their own parsing.
func setScalar(field reflect.Value, s string) error {
if field.CanAddr() {
if u, ok := field.Addr().Interface().(encoding.TextUnmarshaler); ok {
return u.UnmarshalText([]byte(s))
}
}
switch field.Kind() {
case reflect.String:
field.SetString(s)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(s, 10, field.Type().Bits())
if err != nil {
return errNumeric(s, "integer")
}
field.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(s, 10, field.Type().Bits())
if err != nil {
return errNumeric(s, "unsigned integer")
}
field.SetUint(n)
case reflect.Float32, reflect.Float64:
n, err := strconv.ParseFloat(s, field.Type().Bits())
if err != nil {
return errNumeric(s, "number")
}
field.SetFloat(n)
case reflect.Bool:
b, err := strconv.ParseBool(s)
if err != nil {
return fmt.Errorf("%q is not a boolean", s)
}
field.SetBool(b)
default:
// Unreachable: buildPlan rejects unsupported types at wiring time.
return fmt.Errorf("unsupported type %s", field.Type())
}
return nil
}
func errNumeric(s, kind string) error {
return fmt.Errorf("%q is not a valid %s", s, kind)
}