feat(lagoon): accept string and url rules in lagoon.Validate (quick-261104-f4x)

- string requires a string value (pointers and named string types
  included), url a URL matching Laravel's pattern (urlPattern, shared
  with lagoon.ValidateRequest); both skip an empty or blank value
- lagoon::validate.string and .url in the en and pl catalogs, English
  fallbacks for a nil translator
- settings saves whose model rules use nullable|string|max:255 or
  nullable|url|max:500 no longer fail with an unrecognized-rule error
This commit is contained in:
Jakub Zych
2026-11-04 11:08:06 +01:00
parent 617eaef78e
commit d2ce3a2c3b
7 changed files with 270 additions and 2 deletions

View File

@@ -0,0 +1,120 @@
package cabana
import (
"context"
"errors"
"reflect"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
)
// stringURLSettings is a settings model whose rules use the Laravel string
// and url tokens the way plugin settings models do (title, feed URL,
// language code).
type stringURLSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Title *string `gorm:"column:title"`
Website *string `gorm:"column:website"`
Language string `gorm:"column:language"`
}
func (stringURLSettings) TableName() string { return "cabana_string_url_settings" }
func (stringURLSettings) Fillable() []string { return []string{"title", "website", "language"} }
func (stringURLSettings) Rules() map[string]string {
return map[string]string{
"title": "nullable|string|max:255",
"website": "nullable|url|max:500",
"language": "nullable|string|max:10",
}
}
const stringURLSettingsFields = "fields:\n title:\n type: text\n website:\n type: text\n language:\n type: text\n"
func compileStringURLSetting(t *testing.T) *CompiledSetting {
t.Helper()
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte(stringURLSettingsFields)}}
item := pact.SettingsItem{Code: "feed", Label: "Feed", Form: "models/settings/fields.yaml", Model: "StringURLSettings", NewModel: func() any { return &stringURLSettings{} }}
setting, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
return setting
}
// TestSettingsStringURLRules runs the validation sequence of
// SettingsService.Put without a database: the string and url rules are
// accepted, valid, empty and null values pass, and an invalid URL is a
// message on the field.
func TestSettingsStringURLRules(t *testing.T) {
setting := compileStringURLSetting(t)
cases := []struct {
name string
body map[string]any
want map[string][]string
}{
{"valid", map[string]any{"title": "Feed", "website": "https://example.com/feed.png", "language": "pl"}, nil},
{"empty website", map[string]any{"website": ""}, nil},
{"null website and title", map[string]any{"website": nil, "title": nil}, nil},
{"invalid website", map[string]any{"website": "not a url"}, map[string][]string{"website": {"The website format is invalid."}}},
}
for _, tc := range cases {
model := &stringURLSettings{}
if err := lagoon.Fill(model, settingWritable(setting), projectSettingBody(setting, tc.body), false); err != nil {
t.Fatalf("%s: fill: %v", tc.name, err)
}
rules := settingRules(setting, model)
got, err := lagoon.Validate(context.Background(), nil, model, rules, valuesForRules(model, rules), nil)
if err != nil {
t.Fatalf("%s: validate: %v", tc.name, err)
}
if len(got) == 0 && len(tc.want) == 0 {
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("%s: messages = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestSettingsStringURLRulesPostgres saves the settings row through
// SettingsService.Put: a valid body is stored, an invalid URL is a 422 on
// the field and leaves the stored row as it was.
func TestSettingsStringURLRulesPostgres(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&stringURLSettings{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&stringURLSettings{}); err != nil {
t.Fatal(err)
}
setting := compileStringURLSetting(t)
svc := SettingsService{DB: db}
ctx := context.Background()
const feedURL = "https://example.com/feed.png"
saved, err := svc.Put(ctx, setting, map[string]any{"title": "Feed", "website": feedURL, "language": "pl"})
if err != nil || !saved.Exists {
t.Fatalf("valid put = %#v err=%v", saved, err)
}
var count int64
if err := db.Model(&stringURLSettings{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("rows = %d err=%v", count, err)
}
_, err = svc.Put(ctx, setting, map[string]any{"website": "not a url"})
var invalid *ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("invalid url error = %T %v", err, err)
}
if got, _ := invalid.Details["website"].([]string); !reflect.DeepEqual(got, []string{"The website format is invalid."}) {
t.Fatalf("details = %#v", invalid.Details)
}
var stored stringURLSettings
if err := db.First(&stored, 1).Error; err != nil {
t.Fatal(err)
}
if stored.Website == nil || *stored.Website != feedURL {
t.Fatalf("stored website = %v", stored.Website)
}
}

View File

@@ -29,7 +29,7 @@ Use cases:
- 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 GORM callback that runs after `lagoon:after_commit` on the same write registers its work too late: the work never runs, and `lagoon.AfterCommit` logs a warning. 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`), `deferred_bindings` set (`lagoon.DeferredBindingMigrations`, under the `lagoon.DeferredHistoryID` history) 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` (where `required` fails on a zero date or time) 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`.
- Validation: `lagoon.Validate` (where `required` fails on a zero date or time) accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `string`, `url`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. `string` requires a string value (a pointer to one included) and `url` a URL matching Laravel's pattern, the one `lagoon.ValidateRequest` uses; both skip an empty or blank value, which is left to `required`. 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`, `between` and `prohibited` (fails for any value `required` would accept, `0` and `false` included; not implicit, so an absent key, `null`, a blank string and an empty array pass, and with no catalog line its message is the key `validation.prohibited`), 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`).

View File

@@ -51,6 +51,8 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
var betweenMin, betweenMax, minArg, maxArg string
hasNumeric := false
hasInteger := false
hasString := false
hasURL := false
for _, tok := range tokens {
name, arg, _ := strings.Cut(tok, ":")
switch name {
@@ -87,6 +89,10 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
}
case "email":
tags = append(tags, "email")
case "string":
hasString = true
case "url":
hasURL = true
case "confirmed":
if fmt.Sprint(val) != fmt.Sprint(values[field+"_confirmation"]) {
return []string{validateMessage(ctx, tr, "confirmed", field, nil)}, nil
@@ -155,6 +161,23 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
if required && isEmptyValue(val) {
return []string{validateMessage(ctx, tr, "required", field, nil)}, nil
}
if hasString || hasURL {
// Like Laravel, string and url skip a blank value; required has
// already handled emptiness.
s, isStr := ruleString(val)
if !isEmptyValue(val) && (!isStr || strings.TrimSpace(s) != "") {
if hasString && !isStr {
return []string{validateMessage(ctx, tr, "string", field, nil)}, nil
}
var msgs []string
if hasURL && (!isStr || !urlPattern.MatchString(s)) {
msgs = append(msgs, validateMessage(ctx, tr, "url", field, nil))
}
if len(msgs) > 0 {
return msgs, nil
}
}
}
usedBetween := betweenMin != "" && betweenMax != ""
if len(tags) > 0 {
msgs := make([]string, 0, len(tags))
@@ -327,6 +350,26 @@ func isIntegerValue(val any) bool {
}
}
// ruleString answers the string a string or url rule checks: it
// dereferences pointers and interfaces (a nil one is no string) and accepts
// any value whose kind is string, named string types included.
func ruleString(val any) (string, bool) {
if val == nil {
return "", false
}
rv := reflect.ValueOf(val)
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
if rv.IsNil() {
return "", false
}
rv = rv.Elem()
}
if rv.Kind() != reflect.String {
return "", false
}
return rv.String(), true
}
func numericString(val any) (string, bool) {
if val == nil {
return "", false
@@ -466,6 +509,10 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + "."
case "boolean":
return "The " + attr + " field must be true or false."
case "string":
return "The " + attr + " must be a string."
case "url":
return "The " + attr + " format is invalid."
default:
return "The " + attr + " is invalid."
}

View File

@@ -1,6 +1,7 @@
package lagoon
import (
"slices"
"strings"
"testing"
"time"
@@ -315,3 +316,99 @@ func TestValidateRequiredZeroDates(t *testing.T) {
}
}
}
// ruleCase is one value under one rule string with the exact messages
// lagoon.Validate must report for the field (nil when it passes).
type ruleCase struct {
name string
rule string
value any
want []string
}
func runRuleCases(t *testing.T, field string, cases []ruleCase) {
t.Helper()
for _, tc := range cases {
errs, err := Validate(t.Context(), nil, nil, map[string]string{field: tc.rule}, map[string]any{field: tc.value}, nil)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got := errs[field]; !slices.Equal(got, tc.want) {
t.Fatalf("%s: %q on %#v = %q, want %q", tc.name, tc.rule, tc.value, got, tc.want)
}
}
}
// TestValidateStringRule: string accepts a string, a pointer to one and a
// named string type, refuses any other value with Laravel's message, and
// skips an empty or blank value, which is required's job.
func TestValidateStringRule(t *testing.T) {
type label string
empty, x := "", "x"
const rule = "nullable|string|max:255"
notString := []string{"The title must be a string."}
runRuleCases(t, "title", []ruleCase{
{"plain", rule, "hello", nil},
{"nil", rule, nil, nil},
{"empty", rule, "", nil},
{"blank", rule, " ", nil},
{"nil pointer", rule, (*string)(nil), nil},
{"pointer to empty", rule, &empty, nil},
{"pointer to value", rule, &x, nil},
{"named type", rule, label("news"), nil},
{"int", rule, 5, notString},
{"bool", rule, true, notString},
{"slice", rule, []string{"a"}, notString},
{"no nullable empty", "string", "", nil},
{"no nullable nil", "string", nil, nil},
{"no nullable int", "string", 5, notString},
{"required empty", "required|string", "", []string{"The title field is required."}},
})
}
// TestValidateURLRule: url accepts what Laravel's pattern accepts (the one
// lagoon.ValidateRequest uses) and skips an empty or blank value.
func TestValidateURLRule(t *testing.T) {
empty, nope := "", "nope"
const rule = "nullable|url|max:500"
invalid := []string{"The website format is invalid."}
runRuleCases(t, "website", []ruleCase{
{"https", rule, "https://example.com/feed.png", nil},
{"localhost port query fragment", rule, "http://localhost:8080/x?y=1#z", nil},
{"unicode host and path", rule, "https://przykład.pl/ścieżka", nil},
{"nil", rule, nil, nil},
{"empty", rule, "", nil},
{"blank", rule, " ", nil},
{"pointer to empty", rule, &empty, nil},
{"words", rule, "not a url", invalid},
{"no scheme", rule, "example.com", invalid},
{"javascript", rule, "javascript:alert(1)", invalid},
{"leading space", rule, " https://example.com", invalid},
{"trailing space", rule, "https://example.com ", invalid},
{"pointer to non-url", rule, &nope, invalid},
{"int", rule, 42, invalid},
})
}
// TestValidateStringURLTranslated: with a translator the string and url
// failures are the lagoon::validate lines of the request locale.
func TestValidateStringURLTranslated(t *testing.T) {
tr := requestTranslator(t)
cases := []struct {
field, rule string
value any
want string
}{
{"title", "nullable|string|max:255", 5, "title musi być ciągiem znaków."},
{"website", "nullable|url|max:500", "not a url", "Format website jest nieprawidłowy."},
}
for _, tc := range cases {
errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{tc.field: tc.rule}, map[string]any{tc.field: tc.value}, tr)
if err != nil {
t.Fatal(err)
}
if got := errs[tc.field]; len(got) != 1 || got[0] != tc.want {
t.Fatalf("%s %q = %q, want %q", tc.field, tc.rule, got, tc.want)
}
}
}

View File

@@ -11,3 +11,5 @@ max: "The :attribute may not be greater than :max."
min: "The :attribute must be at least :min."
oneof: "The selected :attribute is invalid."
mimes: "The :attribute must be a file of the allowed types."
string: "The :attribute must be a string."
url: "The :attribute format is invalid."

View File

@@ -11,3 +11,5 @@ max: ":attribute nie może być dłuższy niż :max znaków."
min: ":attribute musi mieć przynajmniej :min znaków."
oneof: "Zaznaczony :attribute jest nieprawidłowy."
mimes: ":attribute musi być plikiem typu :values."
string: ":attribute musi być ciągiem znaków."
url: "Format :attribute jest nieprawidłowy."