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

@@ -146,8 +146,8 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
}
return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil
}
if !moneyInRange(s, rangeMin, rangeMax) {
return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": rangeMax, "min": rangeMin})}, nil
if msg, failed := numericRangeMessage(ctx, tr, field, s, rangeMin, rangeMax, minArg == "" && betweenMin != "", maxArg == "" && betweenMax != ""); failed {
return []string{msg}, nil
}
tags = withoutTag(tags, "numeric")
}
@@ -202,6 +202,38 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
return nil, nil
}
// numericRangeMessage checks a numeric value against its bounds and answers
// a failure with the message of the bound that failed: min below the lower
// bound, max above the upper, and Laravel's numeric between message when
// that bound came from between.
func numericRangeMessage(ctx context.Context, tr *phrasebook.Translator, field, val, lo, hi string, loFromBetween, hiFromBetween bool) (string, bool) {
r := new(big.Rat)
if _, ok := r.SetString(val); !ok {
return validateMessage(ctx, tr, "max", field, map[string]string{"max": hi, "min": lo}), true
}
below, above := false, false
if lo != "" {
if m, ok := new(big.Rat).SetString(lo); ok && r.Cmp(m) < 0 {
below = true
}
}
if hi != "" {
if m, ok := new(big.Rat).SetString(hi); ok && r.Cmp(m) > 0 {
above = true
}
}
params := map[string]string{"min": lo, "max": hi}
switch {
case (below && loFromBetween) || (above && hiFromBetween):
return validateMessage(ctx, tr, "between.numeric", field, params), true
case below:
return validateMessage(ctx, tr, "min", field, params), true
case above:
return validateMessage(ctx, tr, "max", field, params), true
}
return "", false
}
func splitRule(rule string) []string {
var out []string
for _, p := range strings.Split(rule, "|") {
@@ -307,33 +339,6 @@ func numericString(val any) (string, bool) {
}
}
func moneyInRange(val any, min, max string) bool {
s, ok := numericString(val)
if !ok {
s = strings.TrimSpace(fmt.Sprint(val))
if s == "" || s == "<nil>" {
return true
}
}
r := new(big.Rat)
if _, ok := r.SetString(s); !ok {
return false
}
if min != "" {
m := new(big.Rat)
if _, ok := m.SetString(min); ok && r.Cmp(m) < 0 {
return false
}
}
if max != "" {
m := new(big.Rat)
if _, ok := m.SetString(max); ok && r.Cmp(m) > 0 {
return false
}
}
return true
}
func uniqueOK(tx *gorm.DB, model any, table, column string, val any) (bool, error) {
if tx == nil {
return false, fmt.Errorf("lagoon: unique:%s requires a database handle", table)
@@ -405,6 +410,9 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
}
params["attribute"] = laravelAttribute(field)
key := "lagoon::validate." + rule
if rule == "between.numeric" {
key = "lagoon::validation.between.numeric"
}
if tr != nil {
s := tr.Get(ctx, key, params)
if s != "" && s != key {
@@ -437,6 +445,8 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
return "The " + attr + " must be a file of the allowed types."
case "between":
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + " characters."
case "between.numeric":
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + "."
case "boolean":
return "The " + attr + " field must be true or false."
default: