Artifact ae2159c13cb7c8ae447efa84573508c4e549d096afb92260eac6424676df6d40:
- File
internal/ops/codec.go
— part of check-in
[939f072f70]
at
2026-08-14 20:33:31
on branch trunk
— ops: form/URL-values codec + tag-driven validator
Field names from json tags; validate tags checked at Register time. (user: dmestas, size: 3243) [annotate] [blame] [check-ins using]
// codec.go — the form/URL-values half of the two-codec contract (0001 §9: // In structs must be expressible as both JSON and form values). Field names // come from the `json` tag so both codecs share one vocabulary; there is no // separate `form` tag to drift. package ops import ( "fmt" "net/url" "reflect" "strconv" "strings" ) // DecodeValues fills dst (pointer to struct) from form/query values. Missing // keys leave the zero value; unknown keys are ignored (HTML forms carry CSRF // tokens and submit buttons). Repeated keys populate slice fields. func DecodeValues(values url.Values, dst any) error { rv := reflect.ValueOf(dst) if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Struct { return fmt.Errorf("ops: DecodeValues dst must be *struct, got %T", dst) } sv := rv.Elem() st := sv.Type() for i := 0; i < st.NumField(); i++ { f := st.Field(i) name := fieldName(f) if name == "" || !f.IsExported() { continue } vals, ok := values[name] if !ok || len(vals) == 0 { continue } if err := setField(sv.Field(i), vals); err != nil { return fmt.Errorf("ops: field %q: %w", name, err) } } return nil } // fieldName returns the form/JSON key for a struct field: the json tag's // name segment, the field name if untagged, "" if the field is json:"-". func fieldName(f reflect.StructField) string { tag, ok := f.Tag.Lookup("json") if !ok { return f.Name } name, _, _ := strings.Cut(tag, ",") if name == "-" { return "" } if name == "" { return f.Name } return name } func setField(fv reflect.Value, vals []string) error { if fv.Kind() == reflect.Slice { elem := fv.Type().Elem() out := reflect.MakeSlice(fv.Type(), len(vals), len(vals)) for i, s := range vals { if err := setScalar(out.Index(i), elem.Kind(), s); err != nil { return err } } fv.Set(out) return nil } return setScalar(fv, fv.Kind(), vals[0]) } func setScalar(fv reflect.Value, kind reflect.Kind, s string) error { switch kind { case reflect.String: fv.SetString(s) case reflect.Bool: b, err := strconv.ParseBool(s) if err != nil { return err } fv.SetBool(b) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n, err := strconv.ParseInt(s, 10, fv.Type().Bits()) if err != nil { return err } fv.SetInt(n) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n, err := strconv.ParseUint(s, 10, fv.Type().Bits()) if err != nil { return err } fv.SetUint(n) case reflect.Float32, reflect.Float64: n, err := strconv.ParseFloat(s, fv.Type().Bits()) if err != nil { return err } fv.SetFloat(n) default: return fmt.Errorf("unsupported kind %s (In/Out structs must stay flat)", kind) } return nil } // Wildcards returns the {name} segments of a ServeMux pattern in order, // stripping a trailing "..." — the keys the router copies from // Request.PathValue into the url.Values handed to DecodeValues. func Wildcards(pattern string) []string { var names []string for _, seg := range strings.Split(pattern, "/") { if len(seg) > 1 && seg[0] == '{' && seg[len(seg)-1] == '}' { names = append(names, strings.TrimSuffix(seg[1:len(seg)-1], "...")) } } return names }