feat(httputil): add Bind/BindEmpty request binding from path and query; v1.6.0
Bind and BindEmpty fill Req from path/query/json struct tags and validate once, extending the typed decode->validate->call->encode pipeline to routes with identifiers and filters. Conversion via builtins + encoding.TextUnmarshaler (uuid.UUID, time.Time); malformed value -> 400 naming the parameter; default: applies only when absent; repeated query -> slice; mis-tagged struct panics at wiring. Purely additive; existing adapters unchanged. Coordinated lockstep v1.6.0.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user