- 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
8.9 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Casts and validation | Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with lagoon.Validate and lagoon.ValidateRequest. | database | 50 |
Casts and validation
Eloquent casts a column through $jsonable, $casts and the encrypted cast, and WinterCMS models validate with a $rules array. lagoon keeps both: column types that implement sql.Scanner and driver.Valuer, and a validator that reads the same rule strings.
JSON columns
lagoon.Jsonable is the Go form of $jsonable. It stores any Go value as JSON text in a TEXT column (not jsonb, so data copied from a WinterCMS database fits as it is). The value lives in Data; Valid tells SQL NULL apart from an empty value, because a nil slice and an empty one are different rows:
tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true}
v, _ := tags.Value()
fmt.Println(v)
var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL
v, _ = none.Value()
fmt.Println(v)
var read lagoon.Jsonable[[]string]
_ = read.Scan(`["winter"]`)
fmt.Println(read.Get(), read.Valid)
// Output:
// ["go","cms"]
// <nil>
// [winter] true
Set NullOnEmpty on a slice or map column that should store NULL rather than [] or {} when it is empty. Pick the behaviour the PHP table already has.
Encrypted columns
lagoon.Encrypted is the encrypted cast. It stores AES-256-GCM ciphertext under a key derived from app.key, and decrypts values written under any key in app.previous_keys, so you can rotate the key without rewriting every row at once. The plaintext has one accessor, lagoon.Encrypted.Reveal; printing the value or marshalling it to JSON always gives [redacted]:
// The application publishes the keys from app.key at boot; a test can
// install a key directly.
key := []byte("0123456789abcdef0123456789abcdef")
if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil {
fmt.Println(err)
return
}
token := lagoon.NewEncrypted("s3cret")
stored, _ := token.Value() // what the column holds
fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret"))
var read lagoon.Encrypted
if err := read.Scan(stored); err != nil {
fmt.Println(err)
return
}
out, _ := json.Marshal(map[string]any{"api_token": read})
fmt.Println(read, string(out))
fmt.Println(read.Reveal())
// Output:
// false
// [redacted] {"api_token":"[redacted]"}
// s3cret
The application loads the keys once at boot (lagoon.OpenFromApp calls lagoon.LoadAppKey and lagoon.PublishEncryptionKeys). A missing or short app.key stops the application with an error; there is no default key. Generate one with key:generate, which prints a key and writes nothing:
./bin/acme key:generate
Keep the key in the environment (SUMMER_APP__KEY), not in a committed file.
The ciphertext format is not Laravel's. To import rows that the PHP application encrypted, decrypt them once with lagoon.DecryptLaravelPayload and the old APP_KEY, then save them through lagoon.Encrypted. It is meant for a one-off import, never for reading live data.
Validation
lagoon.Validate checks a map of input values against Laravel-style rule strings and returns the errors in Laravel's shape, a map from field to messages. It returns nil when the input is valid. The second return value is for failures that are not the user's fault, such as an unknown rule or a database error:
rules := map[string]string{
"title": "required|max:10",
"views": "nullable|integer|max:1000",
}
input := map[string]any{"title": "", "views": 5000}
// A nil translator gives the built-in English messages; unique: rules
// need a database handle instead of nil.
errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil)
if err != nil {
fmt.Println(err)
}
out, _ := json.Marshal(errs)
fmt.Println(string(out))
// Output:
// {"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. 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.
A handler validates before it fills and saves the model, as the create example on Models 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, soposts.*.titlechecksposts.0.title,posts.1.titleand 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":[]}againstrequired|array|min:1gives the single messageThe posts field is required.. Withbail, any failure stops the attribute. - The other rules run only when there is a value: they skip an absent attribute, a blank string,
nullundernullableand an absent key undersometimes. min,max,sizeandbetweenmeasure what Laravel measures: the number on anintegerornumericattribute, 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 asmax.stringormax.file.emailis PHP'sFILTER_VALIDATE_EMAIL, the check WinterCMS uses by default, souser@localhostis refused.dateaccepts ISO 8601 dates and date-times,Y/m/d,m/d/Y,d.m.Yandd-m-Y, and refuses dates that do not exist.after_or_equalandbefore_or_equaltake a date,today,tomorrow,yesterdayornow(in UTC), or the name of another field.exists:table,columncounts 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,mimesand the size rules) take alagoon.UploadedFile;lagoon.UploadedFileFromHeaderbuilds 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.