Validation
An input model declares its rules by implementing one method.
type Validatable interface {
Validate(v *muzak.Validation)
}
func (in *CreateUserIn) Validate(v *muzak.Validation) {
v.String(&in.Email).Trim().Lower().Required().Email()
v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword)
v.String(&in.Confirm).Equal(in.Password).Message("must match the password")
v.Number(&in.Age).Between(18, 120)
v.String(&in.Role).OneOf("admin", "editor", "viewer")
v.Slice(&in.Tags).MaxItems(10).Unique().Each(validate.String().MaxLen(20))
}
Implementing it is the only thing needed. There is no option to remember and no pipe to install, so a model cannot be left unvalidated by forgetting one. The method belongs on the pointer type, which is what lets rules name fields by address.
&in.Email is the field, so renaming it is a change the compiler checks, and
v.Number(&in.Email) does not compile. Type v. and your editor lists the kinds; type
v.String(&in.Email). and it lists every rule that applies to a string.
How a rule set behaves
Four things govern every rule set:
- Transforms mutate, and run first.
Trim().Lower()changes what the handler receives. Because they run before the checks,Trim().Required()rejects a field of pure whitespace. - Optional means optional. A field without
Required()skips its remaining checks when it is empty, soMaxLen(20)has nothing to say about a value nobody sent. A nil pointer field is skipped entirely. - One failure per field. The first check that fails reports; a single empty field does not also complain that it is too short and not an email address.
- Guards run first. Validation happens after binding and after every guard, so an
unauthenticated caller gets
401, not a map of your schema.
The entry points
| Call | Field types | Rule set |
|---|---|---|
v.String(&in.F) | string, *string | validate.StringRules |
v.Number(&in.F) | any integer, float, or pointer to one | validate.NumberRules |
v.Slice(&in.F) | []E | validate.SliceRules[E] |
v.Time(&in.F) | time.Time, *time.Time | validate.TimeRules |
v.Value(&in.F) | any type | validate.ValueRules[T] |
v.Nested(&in.F) | another Validatable | |
v.When(cond) | a cross-field condition | |
v.Reject(&in.F, issue) | a failure recorded directly |
String rules
v.String(&in.Username).Trim().Lower().Required().MinLen(2).MaxLen(32).
Matches(`^[a-z0-9_]+$`).
Message("may only contain lower case letters, digits and underscores").
NotOneOf("admin", "root", "support").
Message("is reserved")
| Rule | Effect | Message on failure |
|---|---|---|
Trim() | Transform: removes surrounding whitespace | |
Lower() / Upper() | Transform: folds case | |
Required() | Rejects an empty value | is required |
MinLen(n) / MaxLen(n) / Len(n) | Bounds the length in runes | must be at least 12 characters |
Email() | Parses with net/mail | must be a valid email address |
URL() | Requires an absolute http or https URL with a host | must be a valid absolute http or https URL |
UUID() | Any of the usual spellings | must be a valid UUID |
Matches(pattern) | A regular expression | is not in the expected format |
OneOf(...) | A fixed set, which also becomes the enum in the document | must be one of "admin", "editor", "viewer" |
NotOneOf(...) | A set the value must never take | is not available |
Prefix(s) / Suffix(s) / Contains(s) | Substring checks | must begin with "user_" |
Equal(other) | Matches another value | does not match |
Must(fn) | Your own func(string) error | whatever the function returns |
As(name) | Renames the field in this rule set's messages | |
Message(text) | Overrides the wording of the check written immediately before it |
Lengths are counted as runes rather than bytes, so a name in any script is measured the way a person would count it.
Matches compiles its expression when the rule is declared, so a malformed pattern is a
panic at start-up rather than a failure on the first request that happens to reach it.
URL() rejects every scheme but http and https on purpose. Without that restriction,
javascript:, data: and file: all parse as perfectly valid absolute URLs, and a value
this check approves is exactly the kind of thing that ends up in a redirect, a fetch or an
href with the validator's blessing.
Number rules
v.Number(&in.Age).Required().Between(18, 120)
v.Number(&in.Limit).Clamp(1, 100)
v.Number(&in.Quantity).Positive().MultipleOf(12)
| Rule | Effect | Message on failure |
|---|---|---|
Required() | Rejects a zero value | is required |
Min(n) / Max(n) / Between(lo, hi) | Inclusive bounds | must be between 18 and 120 |
Positive() / Negative() | Compared against zero | must be greater than zero |
MultipleOf(n) | For a quantity that only makes sense in steps | must be a multiple of 12 |
Clamp(lo, hi) | Transform: pulls the value inside the range instead of rejecting it | |
Must(fn) | Your own func(float64) error | whatever the function returns |
Bounds are written as ordinary untyped constants whatever the field's own numeric type, so
Between(18, 120) reads the same on an int, an int64 and a float64.
Required() on a number rejects zero, and zero is indistinguishable from absent for a
numeric field. A field that may legitimately be zero should be a pointer and left optional
rather than marked required.
Clamp is for a value outside the range being a client being imprecise rather than a
client being wrong, such as a page size that should quietly cap rather than fail.
Slice rules
v.Slice(&in.Tags).MaxItems(10).Unique().Each(validate.String().MaxLen(20))
v.Slice(&in.Scores).MinItems(1).Each(validate.Value[int]().Must(positive))
| Rule | Effect | Message on failure |
|---|---|---|
Required() | Rejects an empty or absent collection | is required |
MinItems(n) / MaxItems(n) | Bounds the length | must have at most 10 items |
Unique() | Every element differs, compared with reflect.DeepEqual | must not repeat fine |
Each(rules) | Applies a rule set to every element | reported against the position |
Must(fn) | Your own func([]E) error | whatever the function returns |
Each only accepts a rule set for the element's own type, so Each(validate.String()) on
a slice of integers does not compile. A failure inside an element is reported against that
position, as tags[2], so a client can tell which one to fix.
Time rules
v.Time(&in.StartsAt).Required().After(time.Now())
v.Time(&in.BornOn).Before(eighteenYearsAgo)
v.Time(&in.Window).Between(seasonStart, seasonEnd)
Required() rejects the zero time. The generated schema carries "format": "date-time".
Value rules
v.Value is the escape hatch for anything the typed rule sets do not cover: a custom
type, an enum with its own String method, a struct compared as a whole. Because it is
generic over the field's type, Must and OneOf take that type directly and the compiler
checks the values written at the call site.
type Currency string
v.Value(&in.Currency).Required().OneOf(Currency("EUR"), Currency("USD"), Currency("TRY"))
v.Value(&in.Coordinates).Must(insideServiceArea)
Rules of your own
A rule is an ordinary function, so it needs no registration and is testable on its own.
// commonPasswords stands in for the list a real service would load. Keeping it
// here rather than inside the rule means the rule stays a plain function that a
// test can call directly.
var commonPasswords = map[string]bool{
"password1234": true,
"qwertyuiop12": true,
"letmeinplease": true,
}
// NotACommonPassword rejects a password from the known-bad list.
//
// Its message is phrased to read after the field name, the way every built-in
// rule words its own failures.
func NotACommonPassword(password string) error {
if commonPasswords[strings.ToLower(password)] {
return errors.New("is too common, choose something less guessable")
}
return nil
}
v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword)
func TestNotACommonPassword(t *testing.T) {
if err := NotACommonPassword("password1234"); err == nil {
t.Error("want a common password to be rejected")
}
}
Reusing a rule set
validate.String(), validate.Number(), validate.Slice[E](), validate.Time() and
validate.Value[T]() build unbound rule sets, which is how a constraint stays consistent
across the models that share it.
// validateTag returns the rules every tag must satisfy.
func validateTag() *validate.StringRules {
return validate.String().Trim().Lower().MaxLen(20).
Matches(`^[a-z0-9-]+$`).
Message("may only contain lower case letters, digits and hyphens")
}
v.Slice(&in.Tags).MaxItems(10).Unique().Each(validateTag())
An unbound rule set is also usable directly, which is what makes it testable without a request:
if err := validateTag().Check("Not A Tag"); err == nil {
t.Error("want a spaced tag to be rejected")
}
Cross-field rules
in is right there, so a cross-field rule is ordinary Go.
// A confirmation field.
v.String(&in.Confirm).Equal(in.Password).Message("must match the password")
// A condition that reads better as a rule.
v.When(in.Role == "admin" && in.Age < 21).
Reject(&in.Role, "an admin must be at least 21")
// A condition that reads better as an if.
if in.Start.After(in.End) {
v.Reject(&in.End, "must not be before the start")
}
When returns a condition, and several failures can hang off one test:
v.When(in.Recurring).
Reject(&in.Interval, "is required for a recurring booking").
Reject(&in.EndsAt, "is required for a recurring booking")
Nested models
type CreateOrderIn struct {
Customer CustomerIn `json:"customer"`
Address AddressIn `json:"address"`
}
func (in *CreateOrderIn) Validate(v *muzak.Validation) {
v.Nested(&in.Customer)
v.Nested(&in.Address)
}
func (in *AddressIn) Validate(v *muzak.Validation) {
v.String(&in.City).Trim().Required().MaxLen(64)
v.String(&in.PostCode).Trim().Upper().Required().Matches(`^[0-9]{5}$`)
}
A failure on the nested model's City field is reported as address.city. A nil pointer
is skipped, so an optional nested model needs no guard of its own.
Validating what did not come from a body
Rules bind to the field, not to the location, so a header is checked exactly as a query parameter is and its failure is reported against the name the client actually sent.
func (in *FeedIn) Validate(v *muzak.Validation) {
v.String(&in.SaveData).Trim().Lower().OneOf("on", "off")
v.String(&in.Traceparent).Matches(`^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`).
Message("must be a W3C trace context, such as 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
v.Slice(&in.Tags).MaxItems(5).Each(validate.String().MaxLen(24))
v.String(&in.SessionID).Trim().MinLen(4).MaxLen(64)
v.Number(&in.Limit).Between(1, 100)
}
Transforms run before the checks, which is what lets Save-Data be compared against a
single spelling in the handler afterwards.
What the client sees
Rule failures merge into the same envelope as binding failures, with the location the binder already established, and every field is reported at once.
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "username", "location": "body", "issue": "is reserved" },
{ "field": "email", "location": "body", "issue": "must be a valid email address" },
{ "field": "password", "location": "body", "issue": "must be at least 12 characters" },
{ "field": "tags", "location": "body", "issue": "must not repeat fine" },
{ "field": "limit", "location": "query", "issue": "must be between 1 and 100" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
The field name is derived from the same struct tag the binder used, so a query parameter
and a body member are reported the same way. As(name) overrides it for a field whose tag
reads worse than its purpose:
v.String(&in.DOB).As("date_of_birth").Required()
Rules feed the documentation
The constraints a rule set declares become JSON Schema keywords, so the OpenAPI document carries the limits the code actually enforces.
| Rule | Keyword |
|---|---|
Required() | membership of required |
MinLen / MaxLen | minLength / maxLength |
Min / Max / Between | minimum / maximum |
MultipleOf | multipleOf |
MinItems / MaxItems | minItems / maxItems |
Unique() | uniqueItems |
OneOf(...) | enum |
Matches(p) | pattern |
Email() / URL() / UUID() | format of email, uri, uuid |
Each(...) | the array's items |
"role": { "type": "string", "enum": ["admin", "editor", "viewer"] },
"tags": {
"type": "array", "maxItems": 10, "uniqueItems": true,
"items": { "type": "string", "maxLength": 20, "pattern": "^[a-z0-9-]+$" }
}
The document cannot drift from the validation, because both are read from the same
declaration. A Must rule is opaque by nature and contributes nothing.
Turning validation off for a route
r.Post("/admin/repair", handlers.RepairRecord, muzak.SkipValidation())
Reach for this only where a route must accept input the model itself would reject, such as an administrative endpoint that repairs bad data.
The full example
// CreateUserIn is the JSON body for creating a user.
//
// It is the fullest example in this service: transforms, a custom rule, a
// cross-field comparison and a collection with per-element rules.
type CreateUserIn struct {
Username string `json:"username" doc:"Lower case letters, digits and underscores"`
Email string `json:"email" doc:"Where we reach the user"`
Password string `json:"password" doc:"At least 12 characters"`
Confirm string `json:"confirm_password"`
Age int `json:"age"`
Role string `json:"role" doc:"One of admin, editor or viewer"`
Tags []string `json:"tags,omitzero"`
Website *string `json:"website,omitzero"`
}
// Validate declares what a valid signup looks like.
func (in *CreateUserIn) Validate(v *muzak.Validation) {
v.String(&in.Username).Trim().Lower().Required().MinLen(2).MaxLen(32).
Matches(`^[a-z0-9_]+$`).
Message("may only contain lower case letters, digits and underscores").
NotOneOf("admin", "root", "support").
Message("is reserved")
v.String(&in.Email).Trim().Lower().Required().Email()
v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword)
v.String(&in.Confirm).Equal(in.Password).Message("must match the password")
v.Number(&in.Age).Required().Between(18, 120)
v.String(&in.Role).Required().OneOf("admin", "editor", "viewer")
v.Slice(&in.Tags).MaxItems(10).Unique().Each(validateTag())
v.String(&in.Website).URL()
// A cross-field rule is an ordinary Go expression over the model's fields.
v.When(in.Role == "admin" && in.Age < 21).
Reject(&in.Role, "an admin must be at least 21")
}
Where to go next
Error Handling covers the envelope these failures arrive in, and OpenAPI covers the document the constraints reach.