Files
zeekayandHanzo Dev 3303b3a031 binding: gin is gone — query binding is ours
gin was in go.mod for two reflection helpers and zero routing:

  pkg/http/binding/query.go  ginbinding.MapFormWithTag
  pkg/valuer/valuer.go       ginbinding.BindUnmarshaler

BindUnmarshaler was one method; the Valuer contract now states it directly.
MapFormWithTag is reimplemented in pkg/http/binding/values.go, which is where
it belonged: a binding package should not import a web framework to reflect
over form values.

bindValues is the whole mapper. It carries exactly the semantics 20 bound
structs across this repo actually depend on, enumerated field by field:

  string · bool · int/8/16/32/64 · uint/8/16/32/64 · float32/64
  slices · pointers (nil when the key is absent, allocated when it is not)
  anonymous embedded structs, flattened into the parent namespace
  `,default=` (4 live sites: format, limit, order, orderBy)
  `-` to skip, empty tag falls back to the field name
  Unmarshaler — the one extension point, which is how every valuer enum binds

Dropped as dead weight, none of it reachable from any bound struct: map
targets, multipart, time_format/time_utc/time_location, collection_format,
the `parser` tag, arrays, and the JSON fallback for unrecognised structs.
An unsupported kind is now a named error instead of a silent JSON decode.
`required:"..."` is openapi metadata — MapFormWithTag never read it and
neither does this; a test pins that so nobody re-derives it.

Verified against the code being deleted, not asserted: a differential test
ran gin's MapFormWithTag and bindValues over all 20 real query structs plus
an every-kind fixture, 3520 input cases, comparing decoded structs and
error-ness — identical throughout. Two deliberate mutations (the empty-value
default rule, the pointer-allocation rule) turned it red, so it bites.
That harness is removed here with gin; 82 table cases stand in its place.

go mod tidy also drops goccy/go-yaml, ugorji/go/codec, pelletier/go-toml
and mongo-driver, which were gin codec plugins and nothing else.

Route table unchanged at 367. Build and full suite green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:23:51 -07:00

222 lines
5.6 KiB
Go

package binding
import (
"reflect"
"strconv"
"strings"
"github.com/hanzoai/o11y/pkg/errors"
)
var (
ErrCodeUnsupportedBindingKind = errors.MustNewCode("unsupported_binding_kind")
ErrCodeInvalidBindingTarget = errors.MustNewCode("invalid_binding_target")
)
// Unmarshaler is implemented by types that decode themselves from a single raw
// parameter. It is the one extension point of bindValues: every enum in the
// codebase reaches it through valuer, so a named type never needs a case here.
type Unmarshaler interface {
// UnmarshalParam decodes and assigns a value from a form or query param.
UnmarshalParam(param string) error
}
var unmarshalerType = reflect.TypeFor[Unmarshaler]()
// bindValues maps values onto the fields of ptr, a non-nil pointer to a struct,
// selecting each field by its `tag` struct tag. A field is left untouched when
// its key is absent and it declares no `,default=` option, which is what makes
// a nil pointer field distinguishable from a supplied zero.
func bindValues(ptr any, values map[string][]string, tag string) error {
value := reflect.ValueOf(ptr)
if value.Kind() != reflect.Pointer || value.IsNil() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidBindingTarget, "binding target must be a non-nil pointer, got %T", ptr)
}
_, err := bindValue(value.Elem(), reflect.StructField{}, values, tag)
return err
}
// bindValue binds one value, reporting whether anything was set. Pointers are
// allocated only if their element ends up set; anonymous structs are flattened
// into the parent namespace; every other struct is offered to bindField first
// so that an Unmarshaler wins over its own fields.
func bindValue(value reflect.Value, field reflect.StructField, values map[string][]string, tag string) (bool, error) {
if field.Tag.Get(tag) == "-" {
return false, nil
}
if value.Kind() == reflect.Pointer {
pointer, isNew := value, value.IsNil()
if isNew {
pointer = reflect.New(value.Type().Elem())
}
set, err := bindValue(pointer.Elem(), field, values, tag)
if err != nil || !set {
return false, err
}
if isNew {
value.Set(pointer)
}
return true, nil
}
if value.Kind() != reflect.Struct || !field.Anonymous {
set, err := bindField(value, field, values, tag)
if err != nil {
return false, err
}
if set {
return true, nil
}
}
if value.Kind() == reflect.Struct {
typ, set := value.Type(), false
for i := range typ.NumField() {
structField := typ.Field(i)
if structField.PkgPath != "" && !structField.Anonymous {
continue
}
ok, err := bindValue(value.Field(i), structField, values, tag)
if err != nil {
return false, err
}
set = set || ok
}
return set, nil
}
return false, nil
}
// bindField resolves the key and default for a single struct field and sets it.
func bindField(value reflect.Value, field reflect.StructField, values map[string][]string, tag string) (bool, error) {
key, opts, _ := strings.Cut(field.Tag.Get(tag), ",")
if key == "" {
key = field.Name
}
if key == "" {
return false, nil
}
defaultValue, hasDefault := defaultOption(opts)
vs, ok := values[key]
if !ok && !hasDefault {
return false, nil
}
if value.Kind() == reflect.Slice && !implementsUnmarshaler(value) {
if len(vs) == 0 {
if !hasDefault {
return false, nil
}
vs = []string{defaultValue}
}
slice := reflect.MakeSlice(value.Type(), len(vs), len(vs))
for i, v := range vs {
if err := setValue(v, slice.Index(i)); err != nil {
return false, err
}
}
value.Set(slice)
return true, nil
}
raw := defaultValue
if len(vs) > 0 && vs[0] != "" {
raw = vs[0]
}
return true, setValue(raw, value)
}
// defaultOption reports the `default=` option of a tag, if any.
func defaultOption(opts string) (string, bool) {
for opts != "" {
var opt string
opt, opts, _ = strings.Cut(opts, ",")
if key, value, _ := strings.Cut(opt, "="); key == "default" {
return value, true
}
}
return "", false
}
// setValue parses raw into value. An empty raw is the zero of the kind rather
// than a parse error, so `?limit=` behaves like an omitted limit.
func setValue(raw string, value reflect.Value) error {
if implementsUnmarshaler(value) {
return value.Addr().Interface().(Unmarshaler).UnmarshalParam(raw)
}
if value.Kind() != reflect.String {
raw = strings.TrimSpace(raw)
}
switch value.Kind() {
case reflect.String:
value.SetString(raw)
case reflect.Bool:
if raw == "" {
raw = "false"
}
parsed, err := strconv.ParseBool(raw)
if err != nil {
return err
}
value.SetBool(parsed)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if raw == "" {
raw = "0"
}
parsed, err := strconv.ParseInt(raw, 10, value.Type().Bits())
if err != nil {
return err
}
value.SetInt(parsed)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if raw == "" {
raw = "0"
}
parsed, err := strconv.ParseUint(raw, 10, value.Type().Bits())
if err != nil {
return err
}
value.SetUint(parsed)
case reflect.Float32, reflect.Float64:
if raw == "" {
raw = "0"
}
parsed, err := strconv.ParseFloat(raw, value.Type().Bits())
if err != nil {
return err
}
value.SetFloat(parsed)
case reflect.Pointer:
if value.IsNil() {
value.Set(reflect.New(value.Type().Elem()))
}
return setValue(raw, value.Elem())
default:
return errors.Newf(errors.TypeInvalidInput, ErrCodeUnsupportedBindingKind, "cannot bind %q into %s", raw, value.Type())
}
return nil
}
func implementsUnmarshaler(value reflect.Value) bool {
return value.CanAddr() && value.Addr().Type().Implements(unmarshalerType)
}