Skip to content
quarry Artifact [8cd9d076ce]
Login

Artifact [8cd9d076ce]

Artifact 8cd9d076cee9aff5cc7c6794ed54faa4e49db9ccf6b4f427f9a4c741124d0aa0:


// 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
}