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:
@@ -108,7 +108,7 @@ fmt.Println(string(out))
|
||||
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
|
||||
```
|
||||
|
||||
A zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay`, or a pointer to one, is empty for `required`, so a required date field with no value fails instead of saving year 1. 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.`).
|
||||
A zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay`, or a pointer to one, is empty for `required`, so a required date field with no value fails instead of saving year 1. The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `string`, `url`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. `string` accepts a string or a pointer to one, and `url` a URL that matches Laravel's pattern (the same one request validation uses, so `javascript:` links and addresses without a scheme are refused). Both skip a missing, empty or blank value; use `required` to refuse those. 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.`).
|
||||
|
||||
`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).
|
||||
|
||||
|
||||
120
modules/cabana/settings_string_url_test.go
Normal file
120
modules/cabana/settings_string_url_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user