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

@@ -17,7 +17,8 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
- After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A nested `lagoon.Transaction` must be given the outer transaction's handle: given a root handle it returns an error without running its function, rather than open an independent transaction whose callbacks would wait on the outer one. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error.
- Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`), backend admin identity set (`lagoon.BackendAdminMigrations`) and job-queue set (`lagoon.QueueMigrations`: River's schema pinned at `lagoon.RiverSchemaVersion`, then the `lagoon.JobsTable` record table, under the `lagoon.QueueHistoryID` history), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_<plugin_id>` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history.
- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`.
- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error.
- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. A failed numeric range reports the bound that failed: the `min` message below the lower bound, the `max` message above the upper one, and the numeric `between` message when the bound came from `between`.
- Request validation: `lagoon.ValidateRequest` reproduces Laravel 9 request validation for ported API endpoints, so a 422 body matches the PHP one message for message. It takes the decoded input and an ordered `lagoon.RequestRule` table (attribute names may hold `*` wildcards, expanded against the input to `posts.0.title`), runs the rules of each attribute in order and stops an attribute after a failed implicit rule (`required`, `present`, `filled`, `accepted`) or, under `bail`, after any failure. A non-implicit rule is skipped for an absent attribute, a blank string, a null value under `nullable` and an absent key under `sometimes`. Supported rules: `required`, `present`, `filled`, `accepted`, `nullable`, `sometimes`, `bail`, `array`, `string`, `integer`, `numeric`, `boolean`, `email` (PHP `FILTER_VALIDATE_EMAIL`, WinterCMS's default), `url`, `date`, `after`, `after_or_equal`, `before`, `before_or_equal` (a date, a relative word such as `tomorrow`, or another field), `exists:table,column`, `regex`, `not_regex`, `in`, `not_in`, `file`, `image`, `mimes`, `min`, `max`, `size` and `between`, plus closure rules built with `lagoon.CustomRule`. The size rules compare the number under `numeric` or `integer` (exactly, as decimals), the element count of an array, kilobytes of a `lagoon.UploadedFile`, and otherwise the length in characters, and pick the matching message. Messages come from the `lagoon::validation` catalog in the request locale; `lagoon.ErrorKeys` gives the attribute order of PHP's message bag.
- Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction, and `lagoon.Collate` adds a validated `COLLATE` clause for language-specific text order (for example the ICU collation `pl-x-icu`); lagoon puts no requirement on the database's default locale.
- Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`).
- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value.
@@ -135,6 +136,14 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.Fill` | Allow-listed mass assignment by column name. |
| `lagoon.FillTypeError` | Returned by `lagoon.Fill` when a requested value does not fit its column; `Key` names the column. |
| `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. |
| `lagoon.ValidateRequest` | Laravel 9 request validation of decoded input against an ordered rule table; returns Laravel's errors object. |
| `lagoon.RequestRule` | One attribute of a request rule table: `Field` (wildcards allowed) and its ordered `Rules`. |
| `lagoon.Rule` | One parsed rule; `lagoon.Rule.Name` and `lagoon.Rule.Args` describe it. |
| `lagoon.ParseRules` | Parses a Laravel rule string into rules; keeps `regex:` patterns whole and panics on an unknown rule or an invalid pattern, so rule tables fail at boot. |
| `lagoon.In` | The `in` rule from a list of values, for values that contain commas or quotes (Laravel's `Rule::in`). |
| `lagoon.CustomRule` | A closure rule; its failure message is used as written. |
| `lagoon.UploadedFile` | An uploaded file for the file rules; `lagoon.UploadedFileFromHeader` adapts a `multipart.FileHeader`. |
| `lagoon.ErrorKeys` | The attributes of an errors object in Laravel's order for a rule table. |
| `lagoon.OrderBy` | Allow-listed ORDER BY, with an optional `lagoon.Collate`. |
| `lagoon.Collate` | Order option that sorts the column with a named PostgreSQL collation; the name is validated and quoted. |
| `lagoon.OrderOption` | Option type accepted by `lagoon.OrderBy`. |

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:

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:]
}

View File

@@ -0,0 +1,365 @@
package lagoon
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// requestTranslator loads the framework's lagoon::validation and
// lagoon::validate catalogs from the phrasebook package directory.
func requestTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
files := fstest.MapFS{}
for _, loc := range []string{"en", "pl"} {
for _, group := range []string{"validation", "validate"} {
rel := filepath.Join("lang", loc, group+".yaml")
raw, err := os.ReadFile(filepath.Join("..", "phrasebook", rel))
if err != nil {
t.Fatal(err)
}
files[filepath.ToSlash(rel)] = &fstest.MapFile{Data: raw}
}
}
cat := phrasebook.NewCatalog()
if err := cat.Load("lagoon", files); err != nil {
t.Fatal(err)
}
return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
}
func inLocale(locale string) context.Context {
return towel.WithLocale(context.Background(), locale)
}
func mustValidate(t *testing.T, ctx context.Context, input map[string]any, rules []RequestRule) map[string][]string {
t.Helper()
errs, err := ValidateRequest(ctx, nil, input, rules, requestTranslator(t))
if err != nil {
t.Fatal(err)
}
return errs
}
func TestValidateRequestEmptyArrayStopsAtRequired(t *testing.T) {
rules := []RequestRule{{Field: "posts", Rules: ParseRules("required|array|min:1")}}
input := map[string]any{"posts": []any{}}
got := mustValidate(t, inLocale("pl"), input, rules)
want := map[string][]string{"posts": {"Pole posts jest wymagane."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("pl = %#v, want %#v", got, want)
}
got = mustValidate(t, inLocale("en"), input, rules)
want = map[string][]string{"posts": {"The posts field is required."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("en = %#v, want %#v", got, want)
}
}
func TestValidateRequestWildcardNamesIndexedAttribute(t *testing.T) {
rules := []RequestRule{
{Field: "posts", Rules: ParseRules("required|array|min:1")},
{Field: "posts.*.title", Rules: ParseRules("required|string|max:255")},
{Field: "posts.*.tags.*", Rules: ParseRules("required")},
}
input := map[string]any{"posts": []any{
map[string]any{"title": "Go", "tags": []any{"a", ""}},
map[string]any{"tags": []any{}},
"not an object",
}}
got := mustValidate(t, inLocale("pl"), input, rules)
want := map[string][]string{
"posts.1.title": {"Pole posts.1.title jest wymagane."},
"posts.2.title": {"Pole posts.2.title jest wymagane."},
"posts.0.tags.1": {"Pole posts.0.tags.1 jest wymagane."},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
keys := ErrorKeys(got, rules)
wantKeys := []string{"posts.1.title", "posts.2.title", "posts.0.tags.1"}
if !reflect.DeepEqual(keys, wantKeys) {
t.Fatalf("ErrorKeys = %v, want %v", keys, wantKeys)
}
}
func TestValidateRequestWildcardWithoutParentAddsNothing(t *testing.T) {
// Laravel drops a wildcard rule that has nothing to expand: an absent
// posts produces no posts.*.title attribute, so only posts reports.
rules := []RequestRule{
{Field: "posts", Rules: ParseRules("nullable|array")},
{Field: "posts.*.title", Rules: ParseRules("required")},
}
if got := mustValidate(t, inLocale("en"), map[string]any{}, rules); got != nil {
t.Fatalf("got %v, want nil", got)
}
}
func TestValidateRequestStringLengthCountsCharacters(t *testing.T) {
rules := []RequestRule{{Field: "title", Rules: ParseRules("required|string|max:255")}}
ok := strings.Repeat("ą", 255)
if got := mustValidate(t, inLocale("pl"), map[string]any{"title": ok}, rules); got != nil {
t.Fatalf("255 characters: %v", got)
}
got := mustValidate(t, inLocale("pl"), map[string]any{"title": ok + "ż"}, rules)
want := map[string][]string{"title": {"title nie może być dłuższy niż 255 znaków."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("256 characters = %#v, want %#v", got, want)
}
}
func TestValidateRequestBetweenIntegerBoundary(t *testing.T) {
rules := []RequestRule{{Field: "year", Rules: ParseRules("nullable|integer|between:1889,2100")}}
for _, year := range []any{float64(1889), float64(2100), "1889", nil} {
if got := mustValidate(t, inLocale("en"), map[string]any{"year": year}, rules); got != nil {
t.Fatalf("year %v: %v", year, got)
}
}
for _, year := range []any{float64(1888), float64(2101)} {
got := mustValidate(t, inLocale("en"), map[string]any{"year": year}, rules)
want := map[string][]string{"year": {"The year must be between 1889 and 2100."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("year %v = %#v", year, got)
}
}
got := mustValidate(t, inLocale("en"), map[string]any{"year": 1991.5}, rules)
want := map[string][]string{"year": {"The year must be an integer."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("1991.5 = %#v", got)
}
}
func TestValidateRequestNumericPrecision(t *testing.T) {
rules := []RequestRule{{Field: "market_price", Rules: ParseRules("nullable|numeric|min:0|max:999999.9999")}}
for _, v := range []any{"999999.9999", 999999.9999, float64(0), "0.0001"} {
if got := mustValidate(t, inLocale("en"), map[string]any{"market_price": v}, rules); got != nil {
t.Fatalf("%v: %v", v, got)
}
}
got := mustValidate(t, inLocale("en"), map[string]any{"market_price": float64(1000000)}, rules)
want := map[string][]string{"market_price": {"The market price may not be greater than 999999.9999."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("1000000 = %#v", got)
}
got = mustValidate(t, inLocale("pl"), map[string]any{"market_price": "-0.01"}, rules)
want = map[string][]string{"market_price": {"market price musi być nie mniejszy od 0."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("-0.01 = %#v", got)
}
}
func TestValidateRequestPolishFallsBackToEnglish(t *testing.T) {
restore := requestNow
requestNow = func() time.Time { return time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) }
t.Cleanup(func() { requestNow = restore })
rules := []RequestRule{{Field: "created_at", Rules: ParseRules("nullable|date|after_or_equal:1900-01-01|before_or_equal:tomorrow")}}
for _, v := range []string{"1900-01-01", "2026-10-03", "2026-10-03T00:00:00+00:00"} {
if got := mustValidate(t, inLocale("pl"), map[string]any{"created_at": v}, rules); got != nil {
t.Fatalf("%s: %v", v, got)
}
}
got := mustValidate(t, inLocale("pl"), map[string]any{"created_at": "1899-12-31"}, rules)
want := map[string][]string{"created_at": {"The created at must be a date after or equal to 1900-01-01."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("1899 = %#v", got)
}
got = mustValidate(t, inLocale("pl"), map[string]any{"created_at": "2026-10-04"}, rules)
want = map[string][]string{"created_at": {"The created at must be a date before or equal to tomorrow."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("day after tomorrow = %#v", got)
}
got = mustValidate(t, inLocale("pl"), map[string]any{"created_at": "garbage"}, rules)
want = map[string][]string{"created_at": {
"created at nie jest prawidłową datą.",
"The created at must be a date after or equal to 1900-01-01.",
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("garbage = %#v", got)
}
}
func TestValidateRequestPresenceSemantics(t *testing.T) {
rules := []RequestRule{
{Field: "name", Rules: ParseRules("sometimes|required|string|min:1|max:255")},
{Field: "notes", Rules: ParseRules("nullable|string")},
{Field: "label", Rules: ParseRules("string|max:3")},
{Field: "count", Rules: ParseRules("integer")},
{Field: "body", Rules: ParseRules("string")},
}
input := map[string]any{"notes": nil, "label": " ", "count": "", "body": nil}
got := mustValidate(t, inLocale("en"), input, rules)
want := map[string][]string{"body": {"The body must be a string."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
got = mustValidate(t, inLocale("en"), map[string]any{"name": ""}, rules[:1])
want = map[string][]string{"name": {"The name field is required."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blank name = %#v", got)
}
}
func TestValidateRequestBailAndOrder(t *testing.T) {
rules := []RequestRule{
{Field: "code", Rules: ParseRules("string|min:5|regex:/^[a-z]+$/")},
{Field: "slug", Rules: ParseRules("bail|string|min:5|regex:/^[a-z]+$/")},
}
got := mustValidate(t, inLocale("en"), map[string]any{"code": "A1", "slug": "A1"}, rules)
want := map[string][]string{
"code": {"The code must be at least 5 characters.", "The code format is invalid."},
"slug": {"The slug must be at least 5 characters."},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestValidateRequestCustomRuleMessageVerbatim(t *testing.T) {
lines := CustomRule(func(attribute string, value any) (string, bool) {
s, _ := value.(string)
if strings.Count(s, "\n")+1 > 2 {
return "The tracklist text may not have more than 2 lines.", true
}
return "", false
})
rules := []RequestRule{{Field: "tracklist_text", Rules: append(ParseRules("nullable|string|max:20000"), lines)}}
got := mustValidate(t, inLocale("pl"), map[string]any{"tracklist_text": "a\nb\nc"}, rules)
want := map[string][]string{"tracklist_text": {"The tracklist text may not have more than 2 lines."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v", got)
}
if got := mustValidate(t, inLocale("pl"), map[string]any{"tracklist_text": nil}, rules); got != nil {
t.Fatalf("null text: %v", got)
}
}
func TestValidateRequestParseRules(t *testing.T) {
rs := ParseRules(`nullable|string|regex:/^(a|b),c$/i|in:LP,"EP 7""",CD|max:16`)
var names []string
for _, r := range rs {
names = append(names, r.Name())
}
if want := []string{"nullable", "string", "regex", "in", "max"}; !reflect.DeepEqual(names, want) {
t.Fatalf("names = %v", names)
}
if got := rs[3].Args(); !reflect.DeepEqual(got, []string{"LP", `EP 7"`, "CD"}) {
t.Fatalf("in args = %q", got)
}
rules := []RequestRule{{Field: "v", Rules: rs}}
if got := mustValidate(t, inLocale("en"), map[string]any{"v": "B,c"}, rules); len(got["v"]) != 1 || got["v"][0] != "The selected v is invalid." {
t.Fatalf("got %v", got)
}
for _, bad := range []string{"required|nope", "max", "between:1", "regex:/(?<=a)b/", "exists:users;drop,id", "regex:/a/x"} {
func() {
defer func() {
if recover() == nil {
t.Fatalf("ParseRules(%q) did not panic", bad)
}
}()
ParseRules(bad)
}()
}
}
func TestValidateRequestUploadedFile(t *testing.T) {
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
file := func(name string, size int64, content []byte) UploadedFile {
return UploadedFile{Filename: name, Size: size, Open: func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(content)), nil
}}
}
rules := []RequestRule{{Field: "photo", Rules: ParseRules("required|image|mimes:jpg,jpeg,png,gif,webp|max:10240")}}
if got := mustValidate(t, inLocale("en"), map[string]any{"photo": file("a.png", 10240*1024, png)}, rules); got != nil {
t.Fatalf("10240 KB: %v", got)
}
got := mustValidate(t, inLocale("en"), map[string]any{"photo": file("a.png", 10240*1024+1, png)}, rules)
want := map[string][]string{"photo": {"The photo may not be greater than 10240 kilobytes."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("over limit = %#v", got)
}
got = mustValidate(t, inLocale("pl"), map[string]any{"photo": file("a.txt", 10, []byte("hello"))}, rules)
want = map[string][]string{"photo": {"photo musi być obrazkiem.", "photo musi być plikiem typu jpg, jpeg, png, gif, webp."}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("text file = %#v", got)
}
got = mustValidate(t, inLocale("en"), map[string]any{"photo": file("shell.php", 10, png)}, rules)
if len(got["photo"]) != 2 {
t.Fatalf("php extension = %#v", got)
}
}
func TestValidateRequestEmailURLBoolean(t *testing.T) {
rules := []RequestRule{
{Field: "email", Rules: ParseRules("nullable|email")},
{Field: "url", Rules: ParseRules("nullable|url")},
{Field: "flag", Rules: ParseRules("nullable|boolean")},
}
pass := []map[string]any{
{"email": "jan.kowalski@example.com"},
{"email": "a+b@sub.example.co.uk"},
{"email": `"quoted"@example.com`},
{"email": "x@[127.0.0.1]"},
{"url": "https://www.discogs.com/release/1?x=1#y"},
{"url": "http://192.168.0.1:8080/a"},
{"flag": true}, {"flag": "0"}, {"flag": float64(1)},
}
for _, in := range pass {
if got := mustValidate(t, inLocale("en"), in, rules); got != nil {
t.Fatalf("%v: %v", in, got)
}
}
fail := []map[string]any{
{"email": "user@localhost"},
{"email": "a..b@example.com"},
{"email": "zażółć@example.com"},
{"email": "a@example.1com"},
{"email": strings.Repeat("a", 65) + "@example.com"},
{"url": "not a url"},
{"url": "javascript:alert(1)"},
{"flag": "true"}, {"flag": float64(2)},
}
for _, in := range fail {
if got := mustValidate(t, inLocale("en"), in, rules); got == nil {
t.Fatalf("%v must fail", in)
}
}
}
func TestValidateRequestExistsNeedsDatabase(t *testing.T) {
rules := []RequestRule{{Field: "genre_id", Rules: ParseRules("nullable|integer|exists:genres,id")}}
if _, err := ValidateRequest(context.Background(), nil, map[string]any{"genre_id": float64(3)}, rules, nil); err == nil {
t.Fatal("exists without a database handle must be an error")
}
// A failed integer rule skips exists, as Laravel does for presence rules.
errs, err := ValidateRequest(context.Background(), nil, map[string]any{"genre_id": "x"}, rules, nil)
if err != nil || len(errs["genre_id"]) != 1 {
t.Fatalf("errs %v err %v", errs, err)
}
}
func TestValidateRequestErrorKeysDeclarationOrder(t *testing.T) {
rules := []RequestRule{
{Field: "tracklist", Rules: ParseRules("nullable|array")},
{Field: "tracklist.*.title", Rules: ParseRules("required")},
{Field: "name", Rules: ParseRules("required")},
}
input := map[string]any{"tracklist": []any{
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
}}
errs := mustValidate(t, inLocale("en"), input, rules)
keys := ErrorKeys(errs, rules)
if keys[0] != "name" || keys[1] != "tracklist.0.title" || keys[2] != "tracklist.1.title" || keys[11] != "tracklist.10.title" {
t.Fatalf("keys = %v", keys)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -229,3 +229,43 @@ func TestValidateUniqueRespectsDeletedAt(t *testing.T) {
t.Fatalf("soft-deleted slug should pass unique: %v", errs)
}
}
func TestValidateNumericRangeMessagePicksFailedBound(t *testing.T) {
cases := []struct {
rule string
val any
want string
}{
{"integer|min:0", -1, "The views must be at least 0."},
{"integer|max:10", 11, "The views may not be greater than 10."},
{"numeric|min:0|max:10", "-0.5", "The views must be at least 0."},
{"numeric|min:0|max:10", "10.5", "The views may not be greater than 10."},
{"integer|between:0,10", 11, "The views must be between 0 and 10."},
{"integer|between:0,10", -1, "The views must be between 0 and 10."},
{"integer|between:0,10|min:2", 1, "The views must be at least 2."},
}
for _, c := range cases {
errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": c.rule}, map[string]any{"views": c.val}, nil)
if err != nil {
t.Fatal(err)
}
if got := errs["views"]; len(got) != 1 || got[0] != c.want {
t.Fatalf("%s with %v = %v, want %q", c.rule, c.val, got, c.want)
}
}
errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": "integer|between:0,10"}, map[string]any{"views": 5}, nil)
if err != nil || errs != nil {
t.Fatalf("in range: %v %v", errs, err)
}
}
func TestValidateNumericRangeMessageTranslated(t *testing.T) {
tr := requestTranslator(t)
errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{"year": "integer|between:1889,2100"}, map[string]any{"year": 1700}, tr)
if err != nil {
t.Fatal(err)
}
if got := errs["year"]; len(got) != 1 || got[0] != "year musi zawierać się w granicach 1889 - 2100." {
t.Fatalf("pl between = %v", got)
}
}