Artifact 0f46f671955defbca3eeda2939cc0adeb944375cfa97bd8af542cc5caaf610e2:
- File
internal/ops/codec.go
— part of check-in
[f073a79918]
at
2026-08-15 16:10:40
on branch trunk
— error taxonomy: 404s, themed error pages, message hygiene (tickets 020/040/070/090)
ops.ErrNotFound sentinel; router maps to 404, hides internals on 500; JSON 404 for unmatched API paths; themed web 404/500; zitcli.ErrNoEntry; clean codec parse messages; shunned-diff note. (user: dmestas, size: 3579) [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("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]) } // setScalar parses one value. Parse failures return client-oriented // messages โ these cross the HTTP boundary in 400 bodies, so no stdlib // error text leaks through. 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 fmt.Errorf("must be a boolean, got %q", s) } 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 fmt.Errorf("must be an integer, got %q", s) } 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 fmt.Errorf("must be a non-negative integer, got %q", s) } fv.SetUint(n) case reflect.Float32, reflect.Float64: n, err := strconv.ParseFloat(s, fv.Type().Bits()) if err != nil { return fmt.Errorf("must be a number, got %q", s) } 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 }