// 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
hasDef bool
def float64 // default=: filled in by the form codec when the key is absent
}
// 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 "default":
if !hasArg {
return r, fmt.Errorf("default needs =N")
}
n, err := strconv.ParseFloat(arg, 64)
if err != nil {
return r, fmt.Errorf("default=%s: not a number", arg)
}
r.hasDef, r.def = 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 {
// A rule failure is the CALLER's fault by definition — the
// rules describe valid input. Without the marker every
// oversized query parameter surfaced as a 500 and a journal
// entry, which is a server confessing to a crime the client
// committed (found via ?branch=<68 chars>).
return fmt.Errorf("ops: field %q %v: %w", fieldName(f), err, ErrInvalid)
}
}
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
}