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