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 skips its other rules, andRequired()fails it:v.String(&in.Name).Required()on a*stringreportsis requiredfor{}and for{"name":null}, with the rule's own message if it has one. A pointer field that is meant to be optional must not declareRequired().
A number is the exception, and it is narrower. Zero only counts as empty when the rules would have accepted zero anyway.Between(1, 720)applies to0and rejects it, because a rule set that says zero is out of range should not be the one thing that lets it through. See Number rules. - 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. - A rule binds to the field, not to what the field holds.
v.String(&in.Name)is right forName stringand forName *stringalike: the address is how a failure finds the name it reports under.v.String(in.Name)compiles, because the entry points accept the pointer type, and is refused when the route is compiled: it would report failures with no field name and drop its constraints from the generated document.
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 and requires the domain to be a hostname or a bracketed IP address | must be a valid email address |
URL() | Requires an absolute http or https URL with a hostname (http://:8080/ is refused) | must be a valid absolute http or https URL |
UUID() | The canonical 8-4-4-4-12 form, in either case, and nothing else | 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) / EqualFold(other) | Matches another value, the second ignoring case | does not match |
MatchesNot(pattern) | Rejects a regular expression, for a shape easier to name than the shapes it excludes | is in a format that is not accepted |
NotBlank() | Rejects a value that is nothing but whitespace or invisible characters (zero-width space, Hangul filler, braille blank, format characters), and an absent one | must not be blank |
NoControl() | Rejects control characters, including tabs and line breaks | must not contain control characters |
MinBytes(n) / MaxBytes(n) | Bounds the length in bytes rather than in runes | must be at least 8 bytes |
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 | |
MessageKey(key, args...) | Overrides it with a translation key instead, so the override is translated like every built-in rule |
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 the first time 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. Rules are declared inside Validate, which runs on every request, so compiled
expressions are kept by pattern text, up to 1024 distinct patterns, and a rule declared per
request does not compile again. The expression is Go's RE2 syntax, which is linear in the
length of the input, and it is written into the document as it stands.
UUID() takes only the canonical form. A URN, braces and 32 bare digits name the same
identifier, which is the trouble: a value stored as the client wrote it holds one UUID under
several strings, and the document's format: uuid describes only the canonical one.
Normalise the spelling before validating, or use Matches for a wider set.
URL() also rejects a port above 65535 and a value containing a control, format or
separator character, such as a bidirectional override or a zero-width space, which would
make the address display as another one. HTTPS() and URLWithSchemes() hold to the same.
Base64() rejects a value with a carriage return or a line feed, which Go's decoder skips.
Email() refuses the same invisible characters and the C1 controls, and still accepts
internationalised addresses.
The host of a URL must name a machine. http://:8080/admin has a port and no hostname, and
Go's HTTP client dials an empty hostname as the local machine, so a value approved as somewhere
else on the network would reach a port on the server itself. URL(), HTTPS() and
URLWithSchemes() refuse it like a URL with no host at all; a scheme with no authority, such as
mailto:, still passes on what follows its colon. The domain of an email address must be a
hostname too, which net/mail does not ask: labels of letters in any script, digits and
hyphens, with no hyphen at either end, so a@- and a@foo_bar.com are refused. A bracketed
IP address, as in a@[192.0.2.1], is still accepted.
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.
Addresses and URLs
URL() accepts any absolute http or https URL. These narrow it, which is
usually what a field means.
| Rule | Effect | Message on failure |
|---|---|---|
HTTPS() | An absolute https URL with a hostname | must be a valid https URL |
URLWithSchemes(...) | An absolute URL using one of the given schemes | must be a URL using "s3" or "gs" |
Host() | A DNS hostname, with the label rules a resolver enforces | must be a valid hostname |
IP() / IPv4() / IPv6() | An address of either family, or of one | must be a valid IPv4 address |
CIDR() | A network, as 10.0.0.0/8 | must be a valid network in CIDR notation |
MAC() | A hardware address in any of its spellings | must be a valid MAC address |
URL() refuses every scheme but http and https on purpose. Without a
restriction, javascript:, data: and file: all parse as perfectly valid
absolute URLs, and a value a validator has blessed is exactly the kind of thing
that ends up in a redirect or an href.
IPv6() refuses an IPv4 address written in the mapped form, such as
::ffff:127.0.0.1. It is an IPv4 address wearing a costume, and a field asking
for IPv6 wants one an IPv6-only network can route.
IP() and IPv6() refuse an address with a zone, such as fe80::1%eth0. The zone is free
text to the parser, so an address written as fe80::1% followed by a line break and a
header, or by markup, used to pass as valid, and a zone names an interface on the machine that
wrote it, which means nothing to the one reading it. A field that needs a link-local zone
should take a string and check it with a rule of its own.
Character classes and formats
| Rule | Effect | Message on failure |
|---|---|---|
Alpha() | Letters only | must contain only letters |
Alphanumeric() | Letters and digits | must contain only letters and digits |
Numeric() | Digits only, as characters rather than as a number | must contain only digits |
ASCII() | Printable ASCII, which is space through tilde | must contain only printable ASCII characters |
Slug() | Lower case letters and digits in hyphen-separated groups | must contain only lower case letters, digits and hyphens |
Hex() | Hexadecimal digits, in either case | must be hexadecimal |
HexColour() | A colour, as #1a2b3c, with three, four, six or eight digits | must be a hexadecimal colour, such as #1a2b3c |
Base64() | Decodes as standard base64 | must be valid base64 |
JSON() | A well-formed JSON document, for a field carrying JSON as text | must be valid JSON |
Semver() | A semantic version, as 2.0.0-rc.1+build.5 | must be a semantic version, such as 1.4.0 |
E164() | A telephone number in international format | must be a telephone number in international format |
LanguageTag() | A BCP 47 tag, as pt-BR | must be a valid language tag, such as pt-BR |
Timezone() | A zone spelled exactly as the IANA database spells it, as Europe/Istanbul | must be a known time zone, such as Europe/Istanbul |
CountryCode() / CurrencyCode() | An assigned ISO 3166-1 alpha-2 or ISO 4217 code | must be a known country code |
Letters are Unicode letters rather than the twenty-six of English, so Alpha()
accepts a name in any script. Narrow it by composing: Alpha().ASCII().
CountryCode() and CurrencyCode() look the value up in the assigned registers
rather than counting letters, so XQ is refused although it is two upper case
letters, and a currency withdrawn from the standard is refused although it was
valid a few years ago.
Both tables carry the date they were taken, and both need regenerating when the standards change. The generated document describes the shape rather than the table, because two hundred values in every schema that names a country would be noise rather than documentation.
Reach for OneOf(...) with your own list when a service trades in a known
handful. It is narrower, it becomes an enum in the document, and it cannot go
stale in a way that matters.
Timezone() accepts only names spelled as the IANA database spells them, and resolves them
against the zone data the host or the binary carries, so what passes here is what
time.LoadLocation will later accept. Spellings LoadLocation would also take are refused:
case variants (europe/paris), doubled or trailing slashes, Local, and the posix/ and
right/ names. A zone created after the list built into the package (tzdata 2026c) is refused
until the list is updated. Send canonical names. The names are checked against that list, so
a client sending nonsense pays for a map lookup and nothing grows from what it sends.
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 |
GreaterThan(n) / LessThan(n) | Exclusive bounds, which refuse the bound itself | must be greater than 10 |
Positive() / Negative() | Compared against zero, which is refused | must be greater than zero |
NonNegative() / NonPositive() | Compared against zero, which is allowed | must not be negative |
MultipleOf(n) | For a quantity that only makes sense in steps | must be a multiple of 12 |
Whole() | Nothing after the decimal point, for a float carrying a count | must be a whole number |
Port() | A whole number from 1 to 65535 | must be a port number between 1 and 65535 |
OneOf(...) | A fixed set, which also becomes the enum in the document | must be one of 1 or 2 |
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.
An integer field is compared with its bounds exactly, whatever its size: an int64 of 2^53 + 1
is over Max(1 << 53), although the two are the same number once both are a float64. A
float field is compared as floating point. Two things still see an integer through float64:
a fractional MultipleOf step, and the function given to Must.
The inclusive and exclusive pairs are worth keeping apart. A price that must be above zero
and a quantity that may be zero are different rules, and the generated document says so:
GreaterThan and Positive produce exclusiveMinimum, where Min and NonNegative
produce minimum. A client reading minimum: 0 is being told that zero is allowed.
Zero, absence, and which one you meant
Zero is indistinguishable from absent for a plain numeric field, and the two need different answers. So the rule is narrow: zero skips the remaining checks only when those checks would have accepted zero.
v.Number(&in.Window).Between(1, 720) // 0 is rejected: the bounds exclude it
v.Number(&in.Discount).Between(0, 100) // 0 is accepted: the bounds admit it
v.Number(&in.Retries).Max(10) // 0 is accepted
v.Number(&in.Score).Must(inHouseRule) // 0 skips: your rule is never run speculatively
The first line is the one that used to surprise people. Before 0.2.7 it accepted 0,
the request reached the database, and a CHECK constraint turned what should have been a
422 into a 500. It also made this page's own promise false in the generated document,
which advertised a minimum the server did not enforce.
A number that may legitimately be absent, and whose bounds exclude zero, is a pointer. That is the same rule that already applied to one that may legitimately be zero:
Age *int `json:"age,omitzero"`
v.Number(&in.Age).Between(18, 120) // nil is skipped; 0 is rejected; 25 is checked
Required() still means what it did: it demands presence, and on a plain numeric field it
rejects zero. Reach for it when the bounds themselves admit zero and you still want the
field supplied.
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. Because it
moves a value into range and rejects nothing, the generated document does not describe it as
a range: minimum and maximum would tell a client that 500 is refused.
MultipleOf compares the quotient with its nearest whole number within a few units of
float64 precision, so 19.99 is a multiple of 0.01, and 5.0000000001 is not a multiple
of 5.
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) / Items(n) | Bounds the length, or fixes it | must have at most 10 items |
NotEmpty() | Rejects an empty or absent collection, in words a client can act on | must not be empty |
Contains(v) / Excludes(v) | An element that must be present, or must not be | must contain read |
Unique() | Every element differs, compared as reflect.DeepEqual does, except that a time.Time is the instant it names | 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 |
Unique() is linear in the size of the collection for every element type. Strings, numbers,
booleans, arrays and structs of them, times and netip addresses are hashed as they are; a
pointer, an interface, a slice, a map or a struct holding one is keyed by a canonical encoding
of what it holds. Two pointers to equal values are a repeat, a map is the same map in any
order, a nil slice is not an empty one and a NaN equals nothing. A time.Time is the
instant it names, wherever in an element it sits, so 12:00Z and 13:00+01:00 are a repeat.
The body limit bounds the memory it uses. A collection over a MaxItems or Items bound is
reported as too long and never searched for repeats, whichever rule is declared first, and
neither is needed to protect the server.
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)
| Rule | Effect | Message on failure |
|---|---|---|
Required() | Rejects the zero time | is required |
Before(t) / After(t) / Between(a, b) | Bounds against a moment you name | must be before 2026-01-01T00:00:00Z |
Past() / Future() | Bounds against now, judged when the request is validated | must be in the past |
Within(d) | No further from now than a distance, in either direction | must be within 5m0s of now |
Must(fn) | Your own func(time.Time) error | whatever the function returns |
The generated schema carries "format": "date-time".
Within is the rule a signed request or a replayed event wants: a timestamp far in the
past is stale and one far in the future is a clock that cannot be trusted, and both are the
same mistake.
v.Time(&in.Timestamp).Within(5 * time.Minute)
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")
// The same, naming a translation rather than fixing the wording in English.
v.When(in.Price < in.Cost).
RejectKey(&in.Price, "errors.item.price_below_cost")
// 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")
Messages in another language
Every rule's wording is translated when the application has a locale configured, and nothing in the model says so. A failure reports the rule it came from as well as its English, and the wording is looked up under that rule at render time.
es:
errors:
messages:
blank: "es obligatorio"
too_short:
one: "debe tener al menos 1 carácter"
other: "debe tener al menos %{count} caracteres"
Message fixes the wording in one language on purpose, and clears the rule with it: a
sentence you wrote is the sentence you meant. MessageKey names a translation instead, and
the rule's own values still fill it in, so %{count} works exactly as it does in the
built-in message.
v.String(&in.Password).MinLen(12).MessageKey("errors.password.too_short")
The full picture, including the four scopes a message is looked up under, is in Internationalization.
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.
A rule may also name a member of a struct the model holds by value, without nesting it:
func (in *CreateOrderIn) Validate(v *muzak.Validation) {
v.Number(&in.Price.Amount).Positive()
}
Its failures are reported under the member's path, price.amount, and the document narrows
that member for this use of the struct alone, so another type holding the same struct is not
held to the rule. A struct held behind a pointer lives outside the model, so its members are
validated by nesting it with Nested instead.
A model nested once per element of a collection is named by its position, and is nested by the address of the element itself:
func (in *CreateOrderIn) Validate(v *muzak.Validation) {
for i := range in.Items {
v.Nested(&in.Items[i])
}
}
A failure on the third item's Name is reported as items[2].name. Ranging over the values
(for _, item := range in.Items { v.Nested(&item) }) validates a copy, whose transforms never
reach the collection and whose position cannot be traced.
Nested validates the model when it is called, transforms included, rather than after the
outer Validate returns, so code after it sees the nested model's transforms applied. Its
failures are still listed after the outer model's own.
Nested that would validate nothing is a build error. v.Nested(in.Addr) with a value
instead of a pointer was skipped without a word, and a model whose Validate has a value
receiver bound its rules to a copy, so {"addr":{"city":""}} passed a Required() city and
its transforms were lost. Both are refused when the route is compiled, for the input and for
every model it nests. Pass the model's address, v.Nested(&in.Addr), and declare Validate
on the pointer receiver.
Bounded reports
Validation work and output are bounded, because the length of a report is otherwise chosen by
the client. A report holds at most muzak.MaxValidationDetails failures (100). A full report
ends with one further detail of kind too_many_problems, and nothing is evaluated after it, so
the limit bounds the work as well as the response: correct these and send the request again to
see the rest. A model nested past muzak.MaxNestedDepth (32) levels is not validated. It is
refused, as a failure of kind too_deep on the field that holds it, so the request is not
passed on with part of it unchecked. A recursive model that must go deeper bounds its own
depth with a rule before it nests. Clients that match field names should expect the indexed
form, items[3].name.
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. The exception is a body that failed to decode: the decoder stops at the first member it cannot read, so the model holds part of a body nobody sent, and its rules would report members the client did send as missing. That failure is reported alone, with any path, query, header or cookie parameter that failed beside it, and the model's rules run once the body decodes.
{
"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, for a JSON body member that has no default |
MinLen / MaxLen | minLength / maxLength |
Min / Max / Between | minimum / maximum |
MultipleOf | multipleOf |
MinItems / MaxItems | minItems / maxItems |
Unique() | uniqueItems |
OneOf(...) | enum, which includes null for a pointer that nothing requires, since a nil pointer skips its rules |
Matches(p) | pattern, the Go (RE2) expression as it stands |
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.
A field named by several rule sets, or by several rules of one kind in one chain, is held to
all of them, and so is its schema: the tightest of each bound, only the values every OneOf
list allows, and, where there is more than one pattern, format or multiple, each of them under
allOf. Describing only the last let through what the others refuse.
v.String(&in.Code).Matches(`^[A-Z]+$`).Matches(`^.{3}$`)
"code": {
"type": "string",
"allOf": [{ "pattern": "^[A-Z]+$" }, { "pattern": "^.{3}$" }]
}
A request body's required list follows the same declaration, at every depth: a member is
listed only when a Required() rule refuses a body without it, because the decoder accepts a
body that leaves any member out. Every object a request is read into also says
additionalProperties: false, because the decoder refuses a member it does not know at any
depth, unless the route uses AllowUnknownFields. See
OpenAPI for what that does to a type used as both
a request and a response. Clamp is not described as a range, because it moves a value into
range rather than rejecting one outside it.
The document says less than the server enforces for the rules with no JSON Schema keyword
(Prefix, Suffix, Contains, NotBlank, MinBytes, the date rules and others), and never
more. A JSON body member that is not a pointer still accepts null, which the decoder reads as
the zero value, and the schema does not list null for it.
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.