Artifact 8cd9d076cee9aff5cc7c6794ed54faa4e49db9ccf6b4f427f9a4c741124d0aa0:
- File
internal/ops/validate.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: 3359) [annotate] [blame] [check-ins using]
// validate.go — the tag-driven validator shared by both codecs. Rules live // in a `validate` tag: comma-separated, `required`, `min=N`, `max=N` // (value for numbers, length for strings and slices), `oneof=a|b|c`. // Tag syntax is checked once at Register time so a typo fails at startup, // not on the first request that hits the field. package ops import ( "fmt" "reflect" "slices" "strconv" "strings" ) type rule struct { required bool hasMin bool min float64 hasMax bool max float64 oneof []string } // parseRules parses one `validate` tag. Empty tag → zero rule. func parseRules(tag string) (rule, error) { var r rule if tag == "" { return r, nil } for _, part := range strings.Split(tag, ",") { key, arg, hasArg := strings.Cut(part, "=") switch key { case "required": r.required = true case "min", "max": if !hasArg { return r, fmt.Errorf("%s needs =N", key) } n, err := strconv.ParseFloat(arg, 64) if err != nil { return r, fmt.Errorf("%s=%s: not a number", key, arg) } if key == "min" { r.hasMin, r.min = true, n } else { r.hasMax, r.max = true, n } case "oneof": if !hasArg || arg == "" { return r, fmt.Errorf("oneof needs =a|b|c") } r.oneof = strings.Split(arg, "|") default: return r, fmt.Errorf("unknown rule %q", part) } } return r, nil } // checkValidateTags verifies every `validate` tag on t parses. Called from // Register so malformed rules are a startup error. func checkValidateTags(t reflect.Type) error { for i := 0; i < t.NumField(); i++ { f := t.Field(i) if _, err := parseRules(f.Tag.Get("validate")); err != nil { return fmt.Errorf("field %s: bad validate tag: %w", f.Name, err) } } return nil } // Validate applies each field's `validate` rules to v (a struct value). // Both codecs call it after decoding, before Run. func Validate(v any) error { sv := reflect.ValueOf(v) if sv.Kind() != reflect.Struct { return fmt.Errorf("ops: Validate wants a struct, got %T", v) } st := sv.Type() for i := 0; i < st.NumField(); i++ { f := st.Field(i) r, err := parseRules(f.Tag.Get("validate")) if err != nil { return fmt.Errorf("ops: field %s: bad validate tag: %w", f.Name, err) } if err := applyRules(r, sv.Field(i)); err != nil { return fmt.Errorf("ops: field %q %w", fieldName(f), err) } } return nil } func applyRules(r rule, fv reflect.Value) error { if r.required && fv.IsZero() { return fmt.Errorf("is required") } // Measure: numbers by value, strings/slices by length. var n float64 switch fv.Kind() { case reflect.String, reflect.Slice: n = float64(fv.Len()) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n = float64(fv.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n = float64(fv.Uint()) case reflect.Float32, reflect.Float64: n = fv.Float() default: return nil // bool etc.: only `required` applies } if r.hasMin && n < r.min { return fmt.Errorf("must be >= %v, got %v", r.min, n) } if r.hasMax && n > r.max { return fmt.Errorf("must be <= %v, got %v", r.max, n) } if len(r.oneof) > 0 && fv.Kind() == reflect.String && !fv.IsZero() && !slices.Contains(r.oneof, fv.String()) { return fmt.Errorf("must be one of %s", strings.Join(r.oneof, "|")) } return nil }