feat(12-01): add Laravel request validation to lagoon

- lagoon.ValidateRequest ports Laravel 9 request validation: wildcard
  expansion, implicit-rule stop, bail, sometimes/nullable/blank skipping,
  size messages split by type and character-counted string lengths
- ParseRules, In, CustomRule, UploadedFile and ErrorKeys for rule tables
- pl/en lagoon::validation catalogs ported verbatim from WinterCMS
- lagoon.Validate answers a numeric range failure with the bound that
  failed (min, max or numeric between) instead of always max
This commit is contained in:
Jakub Zych
2026-10-02 11:25:36 +02:00
parent cfe568a214
commit f9b7f2ea33
11 changed files with 2598 additions and 37 deletions

View File

@@ -0,0 +1,732 @@
package lagoon
import (
"context"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"sort"
"strconv"
"strings"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// RequestRule is one attribute of a request rule table: the attribute name,
// which may contain `*` wildcard segments (posts.*.title), and its rules in
// the order Laravel runs them.
type RequestRule struct {
Field string
Rules []Rule
}
// Rule is one parsed validation rule: a named Laravel rule with its
// parameters, or a closure built with CustomRule. Build rules with
// ParseRules, In or CustomRule; the zero Rule is ignored.
type Rule struct {
name string
args []string
custom func(attribute string, value any) (message string, failed bool)
re *compiledRegex
}
// Name returns the snake-case rule name (required, max, regex) or "custom"
// for a CustomRule.
func (r Rule) Name() string {
if r.custom != nil {
return "custom"
}
return r.name
}
// Args returns the rule parameters as written after the colon.
func (r Rule) Args() []string {
return append([]string(nil), r.args...)
}
// CustomRule wraps a closure rule, the Go form of a PHP `function
// ($attribute, $value, $fail)` rule. It runs in its declaration position,
// only when the value is present (it is not implicit), and a failure adds
// the returned message as written, after the :attribute placeholder is
// replaced, exactly as Winter adds a closure's $fail message.
func CustomRule(fn func(attribute string, value any) (message string, failed bool)) Rule {
if fn == nil {
panic("lagoon: CustomRule with a nil func")
}
return Rule{custom: fn}
}
// In is the Go form of Laravel's Rule::in: the value must equal one of
// values. Use it when a value contains a comma or a quote.
func In(values ...string) Rule {
return Rule{name: "in", args: append([]string(nil), values...)}
}
// UploadedFile is an uploaded file offered to the file rules (file, image,
// mimes and the size rules, which measure it in kilobytes). Open returns the
// content; the rules read at most the first 512 bytes to detect the type.
type UploadedFile struct {
Filename string
Size int64
Header textproto.MIMEHeader
Open func() (io.ReadCloser, error)
}
// UploadedFileFromHeader adapts a parsed multipart file part.
func UploadedFileFromHeader(fh *multipart.FileHeader) UploadedFile {
if fh == nil {
return UploadedFile{}
}
return UploadedFile{
Filename: fh.Filename,
Size: fh.Size,
Header: fh.Header,
Open: func() (io.ReadCloser, error) {
return fh.Open()
},
}
}
// ValidateRequest validates decoded request input with Laravel 9 request
// validation semantics and returns Laravel's errors object (attribute to
// ordered messages), or nil when the input passes. The error return is for
// failures that are not the client's fault (a database error in exists:).
//
// The semantics follow Illuminate\Validation\Validator: `*` segments expand
// against the input (posts.0.title, posts.1.title; a wildcard with nothing to
// expand adds no attribute); rules run in order; a non-implicit rule runs only
// when the attribute is present and is skipped for a blank string, for null
// under nullable, and for an absent key under sometimes; an attribute stops
// after a failed implicit rule (required, present, filled, accepted) and,
// under bail, after any failure. Messages come from the lagoon::validation
// catalog in the request locale; size rules pick the numeric, file, array or
// string message by the attribute's type.
func ValidateRequest(ctx context.Context, tx *gorm.DB, input map[string]any, rules []RequestRule, tr *phrasebook.Translator) (map[string][]string, error) {
v := &requestValidator{ctx: ctx, tx: tx, data: input, tr: tr}
if v.data == nil {
v.data = map[string]any{}
}
if err := v.explode(rules); err != nil {
return nil, err
}
for _, attr := range v.order {
ruleset := v.rules[attr]
for _, rule := range ruleset {
if err := v.validateAttribute(attr, rule); err != nil {
return nil, err
}
if v.shouldStop(attr) {
break
}
}
}
if len(v.messages) == 0 {
return nil, nil
}
return v.messages, nil
}
// ErrorKeys returns the attributes of errs in the order Laravel's message bag
// holds them for rules: explicit attributes in declaration order, then the
// attributes each wildcard rule expanded to, rule by rule, in input order
// (array indexes ascending). Callers that compare a 422 body byte for byte
// use it to emit the errors object in PHP's key order.
func ErrorKeys(errs map[string][]string, rules []RequestRule) []string {
type ranked struct {
key string
group int
path []string
}
explicit := map[string]int{}
for i, rr := range rules {
if !strings.Contains(rr.Field, "*") {
if _, ok := explicit[rr.Field]; !ok {
explicit[rr.Field] = i
}
}
}
out := make([]ranked, 0, len(errs))
for key := range errs {
group := len(rules) * 2
if i, ok := explicit[key]; ok {
group = i
} else {
for i, rr := range rules {
if strings.Contains(rr.Field, "*") && wildcardMatches(rr.Field, key) {
group = len(rules) + i
break
}
}
}
out = append(out, ranked{key: key, group: group, path: strings.Split(key, ".")})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].group != out[j].group {
return out[i].group < out[j].group
}
return pathLess(out[i].path, out[j].path)
})
keys := make([]string, len(out))
for i, r := range out {
keys[i] = r.key
}
return keys
}
func pathLess(a, b []string) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] == b[i] {
continue
}
ai, aerr := strconv.Atoi(a[i])
bi, berr := strconv.Atoi(b[i])
if aerr == nil && berr == nil {
return ai < bi
}
return a[i] < b[i]
}
return len(a) < len(b)
}
// wildcardMatches reports whether key is an expansion of pattern: every `*`
// stands for one non-empty segment.
func wildcardMatches(pattern, key string) bool {
ps := strings.Split(pattern, ".")
ks := strings.Split(key, ".")
if len(ps) != len(ks) {
return false
}
for i := range ps {
if ps[i] == "*" {
if ks[i] == "" {
return false
}
continue
}
if ps[i] != ks[i] {
return false
}
}
return true
}
type requestValidator struct {
ctx context.Context
tx *gorm.DB
data map[string]any
tr *phrasebook.Translator
order []string
rules map[string][]Rule
primary map[string]string // expanded attribute -> wildcard pattern
messages map[string][]string
failed map[string]map[string]bool
}
// explode builds the attribute order Laravel's ValidationRuleParser gives:
// explicit attributes keep their declaration position (a later explicit
// declaration of an expanded key replaces its rules), and wildcard
// expansions are appended in expansion order.
func (v *requestValidator) explode(rules []RequestRule) error {
v.rules = map[string][]Rule{}
v.primary = map[string]string{}
seen := map[string]bool{}
for _, rr := range rules {
if rr.Field == "" {
return fmt.Errorf("lagoon: request rule with an empty field")
}
if seen[rr.Field] {
return fmt.Errorf("lagoon: duplicate request rule field %q", rr.Field)
}
seen[rr.Field] = true
v.order = append(v.order, rr.Field)
v.rules[rr.Field] = cleanRules(rr.Rules)
}
for _, rr := range rules {
if !strings.Contains(rr.Field, "*") {
v.rules[rr.Field] = cleanRules(rr.Rules)
continue
}
for _, key := range expandWildcard(v.data, rr.Field) {
if _, ok := v.rules[key]; ok {
v.rules[key] = append(v.rules[key], cleanRules(rr.Rules)...)
} else {
v.order = append(v.order, key)
v.rules[key] = cleanRules(rr.Rules)
}
if _, ok := v.primary[key]; !ok {
v.primary[key] = rr.Field
}
}
delete(v.rules, rr.Field)
v.order = removeString(v.order, rr.Field)
}
return nil
}
func cleanRules(rules []Rule) []Rule {
out := make([]Rule, 0, len(rules))
for _, r := range rules {
if r.name == "" && r.custom == nil {
continue
}
out = append(out, r)
}
return out
}
func removeString(list []string, s string) []string {
out := list[:0]
for _, x := range list {
if x != s {
out = append(out, x)
}
}
return out
}
type expandedKey struct {
key string
leaf bool
}
// expandWildcard lists the concrete attributes a wildcard pattern stands for
// in data, in the order Laravel's ValidationData gathers them: attributes that
// are leaves of the dotted input first, then the rest, each in input order.
// Literal segments after the last `*` always produce the attribute (its value
// may be missing); a missing or scalar node before a `*` produces nothing.
func expandWildcard(data map[string]any, pattern string) []string {
segs := strings.Split(pattern, ".")
last := -1
for i, s := range segs {
if s == "*" {
last = i
}
}
var found []expandedKey
var walk func(node any, present bool, i int, prefix string)
walk = func(node any, present bool, i int, prefix string) {
if i == len(segs) {
found = append(found, expandedKey{key: prefix, leaf: !present || isDotLeaf(node)})
return
}
seg := segs[i]
if seg == "*" {
for _, ch := range children(node) {
walk(ch.value, true, i+1, joinPath(prefix, ch.key))
}
return
}
child, ok := childOf(node, seg)
if i > last {
walk(child, ok, i+1, joinPath(prefix, seg))
return
}
if !ok {
return
}
walk(child, true, i+1, joinPath(prefix, seg))
}
walk(data, true, 0, "")
out := make([]string, 0, len(found))
seen := map[string]bool{}
for _, pass := range []bool{true, false} {
for _, f := range found {
if f.leaf != pass || seen[f.key] {
continue
}
seen[f.key] = true
out = append(out, f.key)
}
}
return out
}
func joinPath(prefix, seg string) string {
if prefix == "" {
return seg
}
return prefix + "." + seg
}
// isDotLeaf reports whether Arr::dot would emit the value as one key: any
// scalar and any empty array.
func isDotLeaf(v any) bool {
return len(children(v)) == 0
}
type childEntry struct {
key string
value any
}
// children lists an array's elements or a map's entries. Go maps carry no
// insertion order, so map entries come sorted by key.
func children(v any) []childEntry {
switch t := v.(type) {
case []any:
out := make([]childEntry, len(t))
for i, x := range t {
out[i] = childEntry{key: strconv.Itoa(i), value: x}
}
return out
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]childEntry, len(keys))
for i, k := range keys {
out[i] = childEntry{key: k, value: t[k]}
}
return out
case []string:
out := make([]childEntry, len(t))
for i, x := range t {
out[i] = childEntry{key: strconv.Itoa(i), value: x}
}
return out
case []map[string]any:
out := make([]childEntry, len(t))
for i, x := range t {
out[i] = childEntry{key: strconv.Itoa(i), value: x}
}
return out
}
return nil
}
func childOf(v any, seg string) (any, bool) {
switch t := v.(type) {
case map[string]any:
x, ok := t[seg]
return x, ok
case []any:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
return nil, false
}
return t[i], true
case []string:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
return nil, false
}
return t[i], true
case []map[string]any:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
return nil, false
}
return t[i], true
}
return nil, false
}
// lookup is Arr::get/Arr::has over the dotted attribute.
func (v *requestValidator) lookup(attr string) (any, bool) {
var node any = v.data
for _, seg := range strings.Split(attr, ".") {
x, ok := childOf(node, seg)
if !ok {
return nil, false
}
node = x
}
return node, true
}
func (v *requestValidator) hasRule(attr string, names ...string) bool {
for _, r := range v.rules[attr] {
for _, n := range names {
if r.custom == nil && r.name == n {
return true
}
}
}
return false
}
func (v *requestValidator) validateAttribute(attr string, rule Rule) error {
value, present := v.lookup(attr)
if !v.isValidatable(rule, attr, value, present) {
return nil
}
if rule.custom != nil {
msg, failed := rule.custom(attr, value)
if failed {
v.addMessage(attr, v.replaceAttribute(msg, attr))
v.markFailed(attr, "custom")
}
return nil
}
ok, err := v.passes(rule, attr, value, present)
if err != nil {
return err
}
if !ok {
v.addFailure(attr, rule, value)
}
return nil
}
func (v *requestValidator) isValidatable(rule Rule, attr string, value any, present bool) bool {
implicit := rule.isImplicit()
// presentOrRuleIsImplicit
if s, ok := value.(string); ok && phpTrim(s) == "" && present {
if !implicit {
return false
}
} else if !present && !implicit {
return false
}
// passesOptionalCheck
if v.hasRule(attr, "sometimes") && !present {
return false
}
// isNotNullIfMarkedAsNullable
if !implicit && v.hasRule(attr, "nullable") && present && value == nil {
return false
}
// hasNotFailedPreviousRuleIfPresenceRule
if rule.custom == nil && (rule.name == "exists" || rule.name == "unique") && len(v.messages[attr]) > 0 {
return false
}
return true
}
func (v *requestValidator) shouldStop(attr string) bool {
if v.hasRule(attr, "bail") {
return len(v.messages[attr]) > 0
}
if !v.hasRule(attr, implicitRuleNames...) {
return false
}
for name := range v.failed[attr] {
if isImplicitName(name) {
return true
}
}
return false
}
func (v *requestValidator) addMessage(attr, msg string) {
if v.messages == nil {
v.messages = map[string][]string{}
}
v.messages[attr] = append(v.messages[attr], msg)
}
func (v *requestValidator) markFailed(attr, rule string) {
if v.failed == nil {
v.failed = map[string]map[string]bool{}
}
if v.failed[attr] == nil {
v.failed[attr] = map[string]bool{}
}
v.failed[attr][rule] = true
}
func (v *requestValidator) addFailure(attr string, rule Rule, value any) {
msg := v.message(attr, rule.name)
msg = v.replaceAttribute(msg, attr)
msg = replaceInput(msg, value)
msg = replaceIndexes(msg, attr)
msg = v.replaceRule(msg, attr, rule)
v.addMessage(attr, msg)
v.markFailed(attr, rule.name)
}
const catalogPrefix = "lagoon::validation."
// message is Winter's FormatsMessages::getMessage: a custom line for the
// attribute and rule, then the typed line of a size rule, then the rule line.
func (v *requestValidator) message(attr, rule string) string {
if s, ok := v.line("custom." + attr + "." + rule); ok {
return s
}
if sizeRuleNames[rule] {
if s, ok := v.line(rule + "." + v.attributeType(attr)); ok {
return s
}
return "validation." + rule + "." + v.attributeType(attr)
}
if s, ok := v.line(rule); ok {
return s
}
return "validation." + rule
}
func (v *requestValidator) line(key string) (string, bool) {
if v.tr == nil {
return "", false
}
full := catalogPrefix + key
if !v.tr.Has(full) {
return "", false
}
s := v.tr.Get(v.ctx, full, nil)
if s == full {
return "", false
}
return s, true
}
func (v *requestValidator) attributeType(attr string) string {
switch {
case v.hasRule(attr, numericRuleNames...):
return "numeric"
case v.hasRule(attr, "array"):
return "array"
}
if val, ok := v.lookup(attr); ok {
if _, isFile := asUploadedFile(val); isFile {
return "file"
}
}
return "string"
}
// displayableAttribute is Laravel's getDisplayableAttribute: a catalog
// attribute name for the attribute or its wildcard pattern, the raw name of
// an expanded attribute, else the snake-cased name with spaces.
func (v *requestValidator) displayableAttribute(attr string) string {
names := []string{attr}
primary, expanded := v.primary[attr]
if expanded && primary != attr {
names = append(names, primary)
}
for _, n := range names {
if s, ok := v.line("attributes." + n); ok {
return s
}
}
if expanded {
return attr
}
return strings.ReplaceAll(laravelSnake(attr), "_", " ")
}
func (v *requestValidator) replaceAttribute(msg, attr string) string {
name := v.displayableAttribute(attr)
return strings.NewReplacer(
":attribute", name,
":ATTRIBUTE", strings.ToUpper(name),
":Attribute", phpUcfirst(name),
).Replace(msg)
}
func (v *requestValidator) replaceRule(msg, attr string, rule Rule) string {
switch rule.name {
case "between":
return strings.NewReplacer(":min", argAt(rule.args, 0), ":max", argAt(rule.args, 1)).Replace(msg)
case "min":
return strings.ReplaceAll(msg, ":min", argAt(rule.args, 0))
case "max":
return strings.ReplaceAll(msg, ":max", argAt(rule.args, 0))
case "size":
return strings.ReplaceAll(msg, ":size", argAt(rule.args, 0))
case "in", "not_in", "mimes":
return strings.ReplaceAll(msg, ":values", strings.Join(rule.args, ", "))
case "after", "after_or_equal", "before", "before_or_equal":
arg := argAt(rule.args, 0)
if _, ok := parseDateArg(arg); !ok {
return strings.ReplaceAll(msg, ":date", v.displayableAttribute(arg))
}
return strings.ReplaceAll(msg, ":date", arg)
}
return msg
}
func argAt(args []string, i int) string {
if i < len(args) {
return args[i]
}
return ""
}
// replaceInput replaces :input with a scalar value, as Laravel does.
func replaceInput(msg string, value any) string {
if !strings.Contains(msg, ":input") {
return msg
}
s, ok := phpScalarString(value)
if !ok {
return msg
}
return strings.ReplaceAll(msg, ":input", s)
}
// replaceIndexes replaces :index and :position (zero- and one-based) with
// the first numeric segment of the attribute.
func replaceIndexes(msg, attr string) string {
if !strings.Contains(msg, ":index") && !strings.Contains(msg, ":position") {
return msg
}
for _, seg := range strings.Split(attr, ".") {
if n, err := strconv.Atoi(seg); err == nil {
msg = strings.ReplaceAll(msg, ":index", strconv.Itoa(n))
msg = strings.ReplaceAll(msg, ":position", strconv.Itoa(n+1))
break
}
}
return msg
}
// laravelSnake ports Str::snake with the underscore delimiter.
func laravelSnake(s string) string {
if isCtypeLower(s) {
return s
}
s = phpUcwords(s)
var b strings.Builder
first := true
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v' {
continue
}
if !first && r >= 'A' && r <= 'Z' {
b.WriteByte('_')
}
b.WriteRune(r)
first = false
}
return strings.ToLower(b.String())
}
func isCtypeLower(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < 'a' || s[i] > 'z' {
return false
}
}
return true
}
func phpUcwords(s string) string {
b := []byte(s)
start := true
for i, c := range b {
if start && c >= 'a' && c <= 'z' {
b[i] = c - 'a' + 'A'
}
start = c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'
}
return string(b)
}
// phpUcfirst ports Str::ucfirst, which upper-cases the first character
// multibyte-safely.
func phpUcfirst(s string) string {
r, size := utf8.DecodeRuneInString(s)
if size == 0 || r == utf8.RuneError {
return s
}
return strings.ToUpper(string(r)) + s[size:]
}