kanna

kanna / validate

kanna-validate

v0.0.11 Go 1.27 pkg.go.dev

CI codecov Go Reference License: MIT Release 日本語

kanna-validate generates a Validate method from the validate tags of a struct — plain if statements, no reflection — and reports whatever breaks under Rails' error codes, keyed by the fields' JSON names.

Part of the kanna family of code generators. Requires Go 1.27, for encoding/json/v2.

Install

go get -tool github.com/go-kanna/validate/cmd/kanna-validate

The generated code calls the small runtime at the root of this module, github.com/go-kanna/validate, which the line above already requires.

Use

Mark a struct //kanna:validate and write its rules as you would for go-playground/validator:

package user

//go:generate go tool kanna-validate

//kanna:validate
type CreateUserRequest struct {
	Name     string   `json:"name"     validate:"required,max=50"`
	Age      int      `json:"age"      validate:"gte=18,lte=130"`
	Role     string   `json:"role"     validate:"required,oneof=admin member guest"`
	Tags     []string `json:"tags"     validate:"max=5,dive,max=20"`
	Address  Address  `json:"address"  validate:"required"`
	Password string   `json:"password" validate:"required,min=12"`
	Confirm  string   `json:"confirm"  validate:"eqfield=Password"`
}

//kanna:validate
type Address struct {
	Zip string `json:"zip" validate:"required,pattern=^\\d{3}-\\d{4}$"`
}

go generate ./... writes validate_gen.go next to it:

// Code generated by kanna-validate. DO NOT EDIT.

func (x CreateUserRequest) Validate() error {
	var errs validate.Errors

	if x.Name == "" {
		errs.Add("name", validate.Blank())
	} else {
		if utf8.RuneCountInString(x.Name) > 50 {
			errs.Add("name", validate.TooLong(50))
		}
	}

	if x.Age < 18 {
		errs.Add("age", validate.GreaterThanOrEqualTo(18))
	}
	// ...

	for i := range x.Tags {
		if utf8.RuneCountInString(x.Tags[i]) > 20 {
			errs.Add(validate.Index("tags", i), validate.TooLong(20))
		}
	}

	if x.Address == (Address{}) {
		errs.Add("address", validate.Blank())
	} else {
		errs.Nest("address", x.Address.Validate())
	}
	// ...

	return errs.Err()
}

Every rule is an if you can read and stop at in a debugger, and changing a tag changes the generated code, so a change of rules shows up in review.

A rule that does not exist or does not fit its field stops generation, rather than the first request that meets it:

$ go generate ./...
./request.go:5:2: error: unknown rule "requried" (field Name)
  did you mean "required"?
./request.go:6:2: error: rule "email" cannot apply to int (field Age)
./request.go:7:2: error: rule "max": 300 does not fit uint8 (field Level)
./request.go:8:2: error: invalid pattern: error parsing regexp: missing closing ]: `[0-9` (field Zip)
./request.go:10:2: error: eqfield=Pasword: no such field (field Confirm)
  did you mean "Password"?

A misspelled directive, which would otherwise leave the struct without a Validate and say nothing, draws a warning:

./request.go:3:1: warning: "//kanna:validte" is not recognized as a directive
  did you mean "//kanna:validate"?

Rules

The tags are go-playground/validator's; the codes and params are those of Rails' ActiveModel::Validations.

Tag Applies to Code Params
required anything with a zero value blank
min, max, len strings (in characters), slices, maps too_short, too_long, wrong_length count
min, max numbers greater_than_or_equal_to, less_than_or_equal_to count
gt, gte, lt, lte, eq, ne numbers greater_than, greater_than_or_equal_to, less_than, ..., other_than count
oneof=a b c strings, integers inclusion
eqfield=Field a field of the same type confirmation attribute: the other's JSON name
pattern=regexp strings invalid
email, url, uuid strings email, url, uuid, from go-kanna/value
rule=Type, rule=pkg.Type what Type holds or validates the rule's own the rule's own
omitempty anything with a zero value lets the zero value past the rules after it
dive slices, arrays, maps applies the rules after it to each element, or each value of a map

Where it differs from go-playground:

  • Every rule a field breaks is reported, not just the first. A value that fails required is the exception: it is reported blank alone, since anything else it breaks only says again that it is missing.
  • required means "not the zero value": "", 0, false, nil, or IsZero() for a type that has the method. An empty slice that is not nil is there; bound its length with min=1. A 0 sent is the 0 never sent, so where a 0, or a value type whose rule accepts its zero, is a value of its own, take a pointer: required then reports only nil.
  • A nil pointer skips every rule but required: a *string tagged max=20 accepts nil, one tagged required,max=20 does not.
  • A floating-point NaN breaks every comparison it is held to: min=0 and max=100 both report it. The generated code negates the requirement, !(x >= 0), as x < 0 would let NaN through.
  • pattern= takes the rest of the tag, commas included, so it comes last. The tag value is a Go string literal, so a backslash is written \\; a tag that does not unquote stops generation instead of being skipped in silence.
  • omitempty comes first among the rules of its level.

rule=Type applies the rule of Type to the field's value, or to each element after dive. Type is a value type of kanna-value, known by the shape kanna-value gives it, whose constructor NewType(T) (Type, error) holds a value to its rule; an enum of kanna-value, whose constructor NewType(string) (Type, error) accepts only its constants; or a type with a method Validate(T) error, called on its zero value. A bare Type is one of the struct's package. pkg.Type names the package by its name, among the packages of your module and those the struct's package imports, directly or not; a/pkg.Type, with as much of the import path as it takes, tells two packages of one name apart. The struct's file need not import it, since the generated file does. The generated code records at the field what the constructor or Validate returns, with the code and params the rule gave it.

Nesting and hooks

A field whose type has Validate() error — another //kanna:validate struct, a type validating itself by hand, an interface — is validated with it, and its errors are nested under the field: address.zip, items[2].sku, or labels.primary for a map. Slices, arrays, and maps of such types are validated element by element without a dive. An embedded struct without a JSON name keeps its errors at the level of the struct embedding it, as encoding/json flattens its fields.

A rule no tag can express, such as one spanning two fields, goes in a validate method, which the generated Validate calls after every tag has been checked:

func (x CreateUserRequest) validate(errs *validate.Errors) {
	if x.Role == "admin" && x.Age < 21 {
		errs.Add("age", validate.Fail("too_young_for_admin"))
	}
}

An error concerning the struct as a whole goes under validate.Base, "base", as in Rails.

Errors

Validate returns validate.Errors, or nil. It encodes as Rails encodes errors.details, with the fields in the order they first broke:

{
  "name": [{"error": "blank"}],
  "tags[1]": [{"error": "too_long", "count": 20}],
  "confirm": [{"error": "confirmation", "attribute": "password"}]
}

A field is keyed by the name its json tag gives it, or by its Go name without one, whatever the transport. Behind a gRPC handler, a use case input without json tags reports TodoID; tag the field json:"id" to report the name the proto declares. A message that protoc-gen-go generates carries json tags with the proto's field names already.

The rejected value is never part of an error: errors end up in logs, and the value may be personal data.

Decoding requests

validate.DecodeJSON decodes a request body, validates it, and reports everything wrong with it in one go:

var req user.CreateUserRequest
if err := validate.DecodeJSON(r.Body, &req); err != nil {
	if errs, ok := errors.AsType[validate.Errors](err); ok {
		writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": errs})
		return
	}
	writeError(w, http.StatusBadRequest, err) // JSON that cannot be read at all
	return
}

A field whose type rejects its value while decoding — a value type of kanna-value, say — does not stop the decoding: the rejection is reported at the field's path, alongside whatever the tags find in the rest. json.Unmarshal cannot do this; it stops at the first rejection, and does not say which field it came from. DecodeJSON decodes through encoding/json/v2, which matches names case-sensitively and rejects duplicate names.

With kanna-value

kanna-value generates value types that hold a value to its rule wherever it goes: when it is built, decoded, or scanned from a database. The email, url, and uuid tags call the same rules, from its package rule, and report the same codes, so a format checked by a tag and one held in a type read alike in an API response.

//kanna:value
type Email struct{ s string }

func (Email) validate(s string) error { return rule.Email(s) }

//kanna:validate
type SignUp struct {
	Email   Email  `json:"email"   validate:"required"`      // the type holds the format; the tag, that it is there
	Website string `json:"website" validate:"omitempty,url"` // the tag checks the format of a plain string
}

An enum of kanna-value, a string type whose generated UnmarshalText accepts only its constants, needs no oneof listing them again, and reports the same code, inclusion:

//kanna:validate
type ListOrders struct {
	Status *model.OrderStatus `json:"status"` // nil when omitted; "shiped" is reported as inclusion
}

rule= applies the rule of a value type to a plain field, through its constructor. A request built from something other than JSON, such as a gRPC message, can take a *time.Time and check it as model.CompletedAt would:

//kanna:validate
type UpdateTask struct {
	CompletedAt *time.Time `json:"completed_at" validate:"rule=model.CompletedAt"` // nil skips it
}
	if x.CompletedAt != nil {
		if _, err := model.NewCompletedAt(*x.CompletedAt); err != nil {
			errs.Add("completed_at", err)
		}
	}

It checks a plain string against the constants of an enum the same way, as rule=model.OrderStatus.

go-kanna/value is a module of its own: go get github.com/go-kanna/value when you use these tags, or go get -tool github.com/go-kanna/value/cmd/kanna-value for its types. A generated file that needs it while your go.mod does not require it gets a warning rather than a build that fails later:

./validate_gen.go: warning: imports github.com/go-kanna/value/rule, which go.mod does not require; run go mod tidy

Linting

wrapcheck reports return errs.Err() in a hand-written Validate as an unwrapped error from another package. Err hands back what was collected, with nothing underneath to wrap, so add it to the signatures wrapcheck ignores:

linters:
  settings:
    wrapcheck:
      extra-ignore-sigs:
        - validate.Errors).Err(

Flags

kanna-validate [flags] [packages]
Flag Meaning
-tags <list> comma-separated build tags
-check verify the output is up to date instead of writing it, including that no output is left over
-v list the files written, removed, or left unchanged
-version print the version

The packages default to the one in the current directory, so //go:generate go tool kanna-validate needs no argument. A run that succeeds prints nothing but its warnings. One of them is about the run itself: when no struct in its packages is marked //kanna:validate, it succeeds, since a //go:generate line left behind does no harm, but says so, since the line was most likely meant to go as well:

$ go generate ./...
warning: no struct is marked //kanna:validate in example.com/app/user

Code may call Validate before the first run has written it. kanna-validate type-checks the packages it loads once more with what it is about to write in place, and writes nothing while their code does not compile with it: a mistake the missing method hid is reported then, such as passing what Validate returns where it does not fit.

A package whose structs have all lost //kanna:validate has its validate_gen.go removed, unless code in the packages the run loads still calls their Validate; a file of that name without the generated-code header is never touched. The go generate that removes it ends with open ...: no such file or directory, from go generate itself, which lists a package's files before it runs the generators; the file is gone as it should be (golang/go#36068).

Example

example decodes sign-up requests the way a handler would — a valid one, one with everything wrong at once, an empty one, one the hook rejects, and malformed JSON — and prints the response each gets. CI regenerates it and fails if the output would change.

Performance

bench validates a request like the one in Use with the generated Validate and with go-playground/validator, both reading the same tags; len=8 stands in for pattern=, which go-playground does not have. go-playground is set up to report what kanna-validate does — a required struct must not be its zero value, and a field goes by its JSON name — and a test fails unless the two find the same fields broken.

Request Validator ns/op B/op allocs/op
valid kanna-validate 18 0 0
valid go-playground/validator v10.30.5 539 432 18
seven fields broken kanna-validate 231 1032 16
seven fields broken go-playground/validator v10.30.5 989 2210 51

The median of 10 runs with Go 1.27.3 on an Apple M5 Pro. The generated Validate checks a valid request about 30 times as fast as go-playground, and a broken one about 4 times as fast with 53% fewer bytes and 69% fewer allocations. A valid request costs the ifs and allocates nothing; a broken one costs the errors it reports, each built with its code and params.

make bench                       # run it once
make bench BENCHFLAGS=-count=10  # often enough for benchstat

Development

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then build and run it
make bench      # regenerate the benchmark, then run it

License

MIT