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

@@ -1,6 +1,6 @@
--- ---
title: Casts and validation title: Casts and validation
description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with Laravel-style rule strings through lagoon.Validate. description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with lagoon.Validate and lagoon.ValidateRequest.
section: database section: database
order: 50 order: 50
--- ---
@@ -94,11 +94,28 @@ fmt.Println(string(out))
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]} // {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
``` ```
The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. A number below the lower bound gets the `min` message, one above the upper bound the `max` message, and a bound that came from `between` gets the numeric `between` message (`The views must be between 0 and 10.`).
> [!NOTE]
> A failed numeric range check is currently always reported with the `max` message, even when the value is below `min`, and a `min`-only rule then shows an empty limit. Check range errors by field, not by message text.
`unique:<table>` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](../services/localization.md). `unique:<table>` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](../services/localization.md).
A handler validates before it fills and saves the model, as the create example on [Models](models.md) shows. Answer a non-nil error map with status 422 and the body shape the endpoint's existing clients expect. A handler validates before it fills and saves the model, as the create example on [Models](models.md) shows. Answer a non-nil error map with status 422 and the body shape the endpoint's existing clients expect.
## Request validation
`lagoon.Validate` checks a model's rules. A ported API endpoint has a different job: its 422 body has to match the one the PHP endpoint returns, message for message, in the request's language. `lagoon.ValidateRequest` does that by following Laravel 9's request validator rather than the model rules.
It takes the decoded request input and a rule table: a slice of `lagoon.RequestRule`, one per attribute, in the order the PHP controller declares them. Build each attribute's rules from the PHP rule string with `lagoon.ParseRules`, add `lagoon.In` for an `in` list whose values hold commas or quotes, and `lagoon.CustomRule` for a PHP closure rule. A closure's failure message is used exactly as the closure returns it. Keep rule tables in package variables: `lagoon.ParseRules` panics on an unknown rule, a wrong number of parameters or a regular expression that Go cannot compile, so a mistake stops the application at start-up instead of failing a request.
The behaviour follows Laravel:
- An attribute name may contain `*`. It is expanded against the input, so `posts.*.title` checks `posts.0.title`, `posts.1.title` and so on, and the messages name those attributes. A wildcard with nothing to expand checks nothing.
- The rules of an attribute run in order. After a failed `required` (or another implicit rule: `present`, `filled`, `accepted`) the attribute stops, so `{"posts":[]}` against `required|array|min:1` gives the single message `The posts field is required.`. With `bail`, any failure stops the attribute.
- The other rules run only when there is a value: they skip an absent attribute, a blank string, `null` under `nullable` and an absent key under `sometimes`.
- `min`, `max`, `size` and `between` measure what Laravel measures: the number on an `integer` or `numeric` attribute, compared exactly as a decimal; the number of elements of an array; the size of an uploaded file in kilobytes; otherwise the length in characters, not bytes. Each picks the matching message, such as `max.string` or `max.file`.
- `email` is PHP's `FILTER_VALIDATE_EMAIL`, the check WinterCMS uses by default, so `user@localhost` is refused. `date` accepts ISO 8601 dates and date-times, `Y/m/d`, `m/d/Y`, `d.m.Y` and `d-m-Y`, and refuses dates that do not exist. `after_or_equal` and `before_or_equal` take a date, `today`, `tomorrow`, `yesterday` or `now` (in UTC), or the name of another field.
- `exists:table,column` counts matching rows in the database, so it needs the transaction handle. Like Laravel it does not skip soft-deleted rows, and it does not run once the attribute already has a message.
- File rules (`file`, `image`, `mimes` and the size rules) take a `lagoon.UploadedFile`; `lagoon.UploadedFileFromHeader` builds one from a parsed multipart part. The type is read from the file content, not from its name.
The messages come from the `lagoon::validation` catalog, a copy of WinterCMS's validator messages in English and Polish, in the request locale. A message missing in Polish, such as the one for `after_or_equal`, comes out in English, as it does in WinterCMS. Attribute names show with spaces instead of underscores (`market price`), and expanded attributes keep their dotted name.
The result is Laravel's errors object, a map from attribute to messages, or `nil` when the input passes. Go maps have no order, so when an endpoint's body must list the attributes in PHP's order, `lagoon.ErrorKeys` returns them in that order for the rule table.

View File

@@ -81,4 +81,4 @@ The locale lives on the request context, not in a global. For every route, [surf
The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it. The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it.
The framework ships its validation messages (`lagoon::validate`) and admin strings (`backend::lang`) in English and Polish. The framework ships its validation messages (`lagoon::validate` for `lagoon.Validate`, and the full WinterCMS validator catalog `lagoon::validation` for `lagoon.ValidateRequest`) and admin strings (`backend::lang`) in English and Polish.

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. - 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. - 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`. - 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. - 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`). - 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. - 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.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.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.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.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.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`. | | `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 return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil
} }
if !moneyInRange(s, rangeMin, rangeMax) { if msg, failed := numericRangeMessage(ctx, tr, field, s, rangeMin, rangeMax, minArg == "" && betweenMin != "", maxArg == "" && betweenMax != ""); failed {
return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": rangeMax, "min": rangeMin})}, nil return []string{msg}, nil
} }
tags = withoutTag(tags, "numeric") tags = withoutTag(tags, "numeric")
} }
@@ -202,6 +202,38 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
return nil, nil 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 { func splitRule(rule string) []string {
var out []string var out []string
for _, p := range strings.Split(rule, "|") { 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) { func uniqueOK(tx *gorm.DB, model any, table, column string, val any) (bool, error) {
if tx == nil { if tx == nil {
return false, fmt.Errorf("lagoon: unique:%s requires a database handle", table) 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) params["attribute"] = laravelAttribute(field)
key := "lagoon::validate." + rule key := "lagoon::validate." + rule
if rule == "between.numeric" {
key = "lagoon::validation.between.numeric"
}
if tr != nil { if tr != nil {
s := tr.Get(ctx, key, params) s := tr.Get(ctx, key, params)
if s != "" && s != key { 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." return "The " + attr + " must be a file of the allowed types."
case "between": case "between":
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + " characters." 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": case "boolean":
return "The " + attr + " field must be true or false." return "The " + attr + " field must be true or false."
default: 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) 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)
}
}

View File

@@ -12,7 +12,7 @@ phrasebook owns every translatable string in a SummerCMS application. At boot, `
- YAML catalogs laid out as `lang/<locale>/<group>.yaml`; nested maps flatten into keys such as `acme.blog::posts.title` (`phrasebook.Catalog.Load`). Duplicate keys, duplicate namespaces, malformed paths and non-string leaves fail at load time. - YAML catalogs laid out as `lang/<locale>/<group>.yaml`; nested maps flatten into keys such as `acme.blog::posts.title` (`phrasebook.Catalog.Load`). Duplicate keys, duplicate namespaces, malformed paths and non-string leaves fail at load time.
- Overrides laid out as `lang/<locale>/<namespace>/<group>.yaml` that replace keys of any loaded namespace, including the framework admin strings, and may add locales (`phrasebook.Catalog.Override`). - Overrides laid out as `lang/<locale>/<namespace>/<group>.yaml` that replace keys of any loaded namespace, including the framework admin strings, and may add locales (`phrasebook.Catalog.Override`).
- Built-in framework namespaces: `lagoon::validate.*` (validation messages) and `backend::lang.*` (admin UI strings), shipped for `en` and `pl`. - Built-in framework namespaces: `lagoon::validate.*` (the messages of `lagoon.Validate`), `lagoon::validation.*` (the WinterCMS/Laravel validator catalog used by `lagoon.ValidateRequest`, with the size messages split into `numeric`, `file`, `string` and `array`; a key the Polish file lacks, such as `after_or_equal`, falls back to English as in WinterCMS) and `backend::lang.*` (admin UI strings), shipped for `en` and `pl`.
- Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) and then the fallback locale. A missing key returns the key itself. - Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) and then the fallback locale. A missing key returns the key itself.
- Laravel-style placeholders: `:name`, `:Name` (first letter upper-cased) and `:NAME` (upper-cased). - Laravel-style placeholders: `:name`, `:Name` (first letter upper-cased) and `:NAME` (upper-cased).
- Pluralization with `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn`: CLDR plural maps (`one`, `few`, `many`, `other`, ...) validated against the locale's categories, or Laravel pipes with exact (`{0}`) and range (`[2,*]`) conditions. `:count` is filled in automatically. - Pluralization with `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn`: CLDR plural maps (`one`, `few`, `many`, `other`, ...) validated against the locale's categories, or Laravel pipes with exact (`{0}`) and range (`[2,*]`) conditions. `:count` is filled in automatically.

View File

@@ -0,0 +1,102 @@
# Port of WinterCMS modules/system/lang/en/validation.php (Laravel validator
# messages). Text is copied verbatim; keys absent from this locale fall back to
# app.fallback_locale as in Winter. Loaded as lagoon::validation.*
accepted: "The :attribute must be accepted."
active_url: "The :attribute is not a valid URL."
after: "The :attribute must be a date after :date."
after_or_equal: "The :attribute must be a date after or equal to :date."
alpha: "The :attribute may only contain letters."
alpha_dash: "The :attribute may only contain letters, numbers, and dashes."
alpha_num: "The :attribute may only contain letters and numbers."
array: "The :attribute must be an array."
before: "The :attribute must be a date before :date."
before_or_equal: "The :attribute must be a date before or equal to :date."
between:
numeric: "The :attribute must be between :min and :max."
file: "The :attribute must be between :min and :max kilobytes."
string: "The :attribute must be between :min and :max characters."
array: "The :attribute must have between :min and :max items."
boolean: "The :attribute field must be true or false."
confirmed: "The :attribute confirmation does not match."
date: "The :attribute is not a valid date."
date_equals: "The :attribute must be a date equal to :date."
date_format: "The :attribute does not match the format :format."
different: "The :attribute and :other must be different."
digits: "The :attribute must be :digits digits."
digits_between: "The :attribute must be between :min and :max digits."
dimensions: "The :attribute has invalid image dimensions."
distinct: "The :attribute field has a duplicate value."
email: "The :attribute must be a valid email address."
ends_with: "The :attribute must end with one of the following: :values."
exists: "The selected :attribute is invalid."
file: "The :attribute must be a file."
filled: "The :attribute field must have a value."
gt:
numeric: "The :attribute must be greater than :value."
file: "The :attribute must be greater than :value kilobytes."
string: "The :attribute must be greater than :value characters."
array: "The :attribute must have more than :value items."
gte:
numeric: "The :attribute must be greater than or equal :value."
file: "The :attribute must be greater than or equal :value kilobytes."
string: "The :attribute must be greater than or equal :value characters."
array: "The :attribute must have :value items or more."
image: "The :attribute must be an image."
in: "The selected :attribute is invalid."
in_array: "The :attribute field does not exist in :other."
integer: "The :attribute must be an integer."
ip: "The :attribute must be a valid IP address."
ipv4: "The :attribute must be a valid IPv4 address."
ipv6: "The :attribute must be a valid IPv6 address."
json: "The :attribute must be a valid JSON string."
lt:
numeric: "The :attribute must be less than :value."
file: "The :attribute must be less than :value kilobytes."
string: "The :attribute must be less than :value characters."
array: "The :attribute must have less than :value items."
lte:
numeric: "The :attribute must be less than or equal :value."
file: "The :attribute must be less than or equal :value kilobytes."
string: "The :attribute must be less than or equal :value characters."
array: "The :attribute must not have more than :value items."
max:
numeric: "The :attribute may not be greater than :max."
file: "The :attribute may not be greater than :max kilobytes."
string: "The :attribute may not be greater than :max characters."
array: "The :attribute may not have more than :max items."
mimes: "The :attribute must be a file of type: :values."
mimetypes: "The :attribute must be a file of type: :values."
min:
numeric: "The :attribute must be at least :min."
file: "The :attribute must be at least :min kilobytes."
string: "The :attribute must be at least :min characters."
array: "The :attribute must have at least :min items."
not_in: "The selected :attribute is invalid."
not_regex: "The :attribute format is invalid."
numeric: "The :attribute must be a number."
present: "The :attribute field must be present."
regex: "The :attribute format is invalid."
required: "The :attribute field is required."
required_if: "The :attribute field is required when :other is :value."
required_unless: "The :attribute field is required unless :other is in :values."
required_with: "The :attribute field is required when :values is present."
required_with_all: "The :attribute field is required when :values is present."
required_without: "The :attribute field is required when :values is not present."
required_without_all: "The :attribute field is required when none of :values are present."
same: "The :attribute and :other must match."
size:
numeric: "The :attribute must be :size."
file: "The :attribute must be :size kilobytes."
string: "The :attribute must be :size characters."
array: "The :attribute must contain :size items."
starts_with: "The :attribute must start with one of the following: :values."
string: "The :attribute must be a string."
timezone: "The :attribute must be a valid zone."
unique: "The :attribute has already been taken."
uploaded: "The :attribute failed to upload."
url: "The :attribute format is invalid."
uuid: "The :attribute must be a valid UUID."
custom:
attribute-name:
rule-name: "custom-message"
attributes: {}

View File

@@ -0,0 +1,69 @@
# Port of WinterCMS modules/system/lang/pl/validation.php (Laravel validator
# messages). Text is copied verbatim; keys absent from this locale fall back to
# app.fallback_locale as in Winter. Loaded as lagoon::validation.*
accepted: ":attribute musi zostać zaakceptowany."
active_url: ":attribute jest nieprawidłowym adresem URL."
after: ":attribute musi być datą późniejszą od :date."
alpha: ":attribute może zawierać jedynie litery."
alpha_dash: ":attribute może zawierać jedynie litery, cyfry i myślniki."
alpha_num: ":attribute może zawierać jedynie litery i cyfry."
array: ":attribute musi być tablicą."
before: ":attribute musi być datą wcześniejszą od :date."
between:
numeric: ":attribute musi zawierać się w granicach :min - :max."
file: ":attribute musi zawierać się w granicach :min - :max kilobajtów."
string: ":attribute musi zawierać się w granicach :min - :max znaków."
array: ":attribute musi składać się z :min - :max elementów."
boolean: ":attribute musi mieć wartość prawda albo fałsz"
confirmed: "Potwierdzenie :attribute nie zgadza się."
date: ":attribute nie jest prawidłową datą."
date_format: ":attribute nie jest w formacie :format."
different: ":attribute oraz :other muszą się różnić."
digits: ":attribute musi składać się z :digits cyfr."
digits_between: ":attribute musi mieć od :min do :max cyfr."
dimensions: "The :attribute has invalid image dimensions."
distinct: "The :attribute field has a duplicate value."
email: "Format :attribute jest nieprawidłowy."
exists: "Zaznaczony :attribute jest nieprawidłowy."
file: "The :attribute must be a file."
filled: "Pole :attribute jest wymagane."
image: ":attribute musi być obrazkiem."
in: "Zaznaczony :attribute jest nieprawidłowy."
in_array: "The :attribute field does not exist in :other."
integer: ":attribute musi być liczbą całkowitą."
ip: ":attribute musi być prawidłowym adresem IP."
json: "The :attribute must be a valid JSON string."
max:
numeric: ":attribute nie może być większy niż :max."
file: ":attribute nie może być większy niż :max kilobajtów."
string: ":attribute nie może być dłuższy niż :max znaków."
array: ":attribute nie może mieć więcej niż :max elementów."
mimes: ":attribute musi być plikiem typu :values."
min:
numeric: ":attribute musi być nie mniejszy od :min."
file: ":attribute musi mieć przynajmniej :min kilobajtów."
string: ":attribute musi mieć przynajmniej :min znaków."
array: ":attribute musi mieć przynajmniej :min elementów."
not_in: "Zaznaczony :attribute jest nieprawidłowy."
numeric: ":attribute musi być liczbą."
present: "The :attribute field must be present."
regex: "Format :attribute jest nieprawidłowy."
required: "Pole :attribute jest wymagane."
required_if: "Pole :attribute jest wymagane gdy :other jest :value."
required_unless: "The :attribute field is required unless :other is in :values."
required_with: "Pole :attribute jest wymagane gdy :values jest obecny."
required_with_all: "Pole :attribute jest wymagane gdy :values jest obecny."
required_without: "Pole :attribute jest wymagane gdy :values nie jest obecny."
required_without_all: "Pole :attribute jest wymagane gdy żadne z :values nie są obecne."
same: "Pole :attribute i :other muszą się zgadzać."
size:
numeric: ":attribute musi mieć :size."
file: ":attribute musi mieć :size kilobajtów."
string: ":attribute musi mieć :size znaków."
array: ":attribute musi zawierać :size elementów."
string: "The :attribute must be a string."
timezone: ":attribute musi być prawidłową strefą czasową."
unique: "Taki :attribute już występuje."
url: "Format :attribute jest nieprawidłowy."
custom: {}
attributes: {}