README
¶
value
kanna-value turns a struct holding one value and a validate method into a value type — constructors, an accessor,
and JSON and SQL methods — that holds only what its rule accepts, wherever the value comes from.
Part of the kanna family of code generators. Requires Go 1.26.
Install
go get -tool github.com/go-kanna/value/cmd/kanna-value
Rules are written with the package github.com/go-kanna/value/rule, which the line above already requires. It depends
on nothing outside the standard library.
Use
Hold the value in a struct with one unexported field, and write its rule as a validate method:
package model
import "github.com/go-kanna/value/rule"
//go:generate go tool kanna-value
//kanna:value
type Email struct{ s string }
func (Email) validate(s string) error { return rule.Email(s) }
//kanna:value
type CompletedAt struct{ t time.Time }
func (CompletedAt) validate(t time.Time) error {
if t.Before(opened) {
return rule.FailWith("greater_than_or_equal_to", map[string]any{"count": opened})
}
return nil
}
go generate ./... writes value_gen.go next to it:
// Code generated by kanna-value. DO NOT EDIT.
func NewEmail(v string) (Email, error) // checks v with validate
func MustEmail(v string) Email // for a constant: panics if v is rejected
func (x Email) String() string // the string held
func (x Email) IsZero() bool
func (x Email) MarshalJSON() ([]byte, error)
func (x *Email) UnmarshalJSON(data []byte) error // checks what it decodes
func (x Email) Value() (driver.Value, error)
func (x *Email) Scan(src any) error // checks what it scans
func NewCompletedAt(v time.Time) (CompletedAt, error)
func MustCompletedAt(v time.Time) CompletedAt
func (x CompletedAt) Time() time.Time
func (x CompletedAt) Equal(y CompletedAt) bool
// String, IsZero, MarshalJSON, UnmarshalJSON, Value, and Scan, as for Email
The field is unexported, so outside the package a value comes only from NewEmail, MustEmail, UnmarshalJSON, or
Scan, and each of them runs validate. Code receiving an Email need not check it again.
A value type is a type of its own, named as you named it in errors and documentation, and it takes methods like any other:
func (e Email) Domain() string {
_, domain, _ := strings.Cut(e.s, "@")
return domain
}
A struct that cannot become a value type stops generation, rather than the first value that meets it:
$ go generate ./...
./model.go:6:6: error: Email has no validate method to check its values with
add func (Email) validate(string) error, returning nil to accept the value
./model.go:9:17: error: ID.Value must be unexported, so that a value is set only through the generated code
rename it to value
./model.go:14:19: error: Tags holds a []string, which has no name to call the method returning it after
declare a named type for it, and hold that
./model.go:21:1: warning: "//kanna:valeu" is not recognized as a directive
did you mean "//kanna:value"?
Code may call what is generated before it exists, as when a type moves to kanna-value or its callers are written
first. Errors saying only that a constructor or a method the run generates is missing do not stop it; any other does.
Another package whose generator reads the value type, such as a kanna-mapper package, needs its value_gen.go to exist,
so on that first run, generate the value type's package before it.
What is generated
For a struct X holding a value of type T in its field t:
| Declaration | What it does |
|---|---|
NewX(v T) (X, error) |
returns the X holding v if validate accepts it, and its error otherwise |
MustX(v T) X |
NewX for values known to be valid, such as constants; panics if v is rejected |
| accessor | returns the value, named after T: String for a string, Int64 for an int64, Time for a time.Time, UUID for a uuid.UUID |
String() string |
the value's own String if it has one, or else fmt's %v; for a string, the accessor itself |
IsZero() bool |
the value's own IsZero if it has one, or else == with the zero value, or == nil for a slice or a map |
Equal(y X) bool |
only when T has Equal(T) bool, as time.Time does |
MarshalJSON, UnmarshalJSON |
encode the value as T encodes; decode a T and keep it only if validate accepts it |
Value, Scan |
convert the value as database/sql converts a T; scan a T and keep it only if validate accepts it |
An unexported struct gets unexported constructors, newX and mustX.
A few things follow from what T is:
Tis a basic or a named type, after which the accessor is named. A slice is held through a type of its own, such astype tags []string.- A slice or a map is copied on the way in and on the way out, with
slices.Cloneormaps.Clone, so that nothing changes it pastvalidate. What it holds is not copied in turn, nor is a slice inside a struct: hold values that are not changed in place. IsZerotells the zero value as Go does: a nil slice is zero, an empty one is not. ATthat neither==nor== nilnor a method of its own can tell zero, such as a struct holding a slice, gets noIsZero; encoding/json'somitzerofalls back on reflection for it.Equalexists because==can be wrong for a time: two of the same instant differ when one carries a monotonic clock reading or another location, as a time read back from a database does.
Decoding and scanning leave the value as it was on any error, and so does JSON null. A NULL column is an error: scan a
column that may be NULL into a *X, which database/sql sets to nil.
A method or a constructor you have written under one of the generated names stops generation instead of being
overwritten. Equal and IsZero are yours to write when none is generated.
Rules
A validate method returns nil to accept a value, and an error to reject it. The package rule provides what rules are
written with:
| Declaration | Rejects with |
|---|---|
rule.Fail(code), rule.FailWith(code, params) |
an *rule.Error with your code and params |
rule.Email(s) |
email, unless s is a bare address such as a@example.com |
rule.URL(s) |
url, unless s is an absolute URL with a host, such as https://example.com |
rule.UUID(s) |
uuid, unless s is a UUID in its standard textual form, of any version |
A rule is a plain function, so rules combine as any Go code does:
func (CompanyEmail) validate(s string) error {
if err := rule.Email(s); err != nil {
return err
}
if !strings.HasSuffix(s, "@example.com") {
return rule.Fail("corp_domain")
}
return nil
}
The code names the broken rule in a form stable enough to key a translated message or an API response, and the params
carry what the message needs. kanna-validate reports the codes of Rails' ActiveModel::Validations, such as too_long
with a count; using them for your own rules keeps an API's errors alike. An error formats as rule: too_long (count=3). The rejected value is never part of it: errors end up in logs, and the value may be personal data.
With kanna-validate
kanna-validate checks the fields of a request by their tags. A value type takes
care of its own format, so its field needs at most required, which kanna-validate checks with IsZero:
//kanna:validate
type SignUp struct {
Name string `json:"name" validate:"required"`
Email model.Email `json:"email" validate:"required"`
}
validate.DecodeJSON records a value that UnmarshalJSON rejects at its field's path, with its code and params, and
goes on decoding, so the response lists it together with whatever the tags find:
{"errors": {"email": [{"error": "email"}], "name": [{"error": "blank"}]}}
Linting
wrapcheck reports return rule.Email(s) in a validate method as an unwrapped error from another package. The error
is the rule's verdict, with nothing underneath to wrap, so let the package through:
linters:
settings:
wrapcheck:
ignore-package-globs:
- github.com/go-kanna/value/rule
Flags
kanna-value [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-value 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:value, 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:value in example.com/app/model
A package whose structs have all lost //kanna:value has its value_gen.go removed; a file of that name without the
generated-code header is never touched.
Example
example builds the values of a task service from constants and from input, decodes requests holding them
— one valid, one with too many tags, one completed before the service began — and shows that a slice it was given cannot
be changed from outside. CI regenerates it and fails if the output would change.
Development
make test # unit tests
make lint # golangci-lint
make example # regenerate the example, then build and run it
License
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
kanna-value
command
Command kanna-value generates value types from structs marked //kanna:value.
|
Command kanna-value generates value types from structs marked //kanna:value. |
|
internal
|
|
|
gen
Package gen generates value types from structs marked //kanna:value.
|
Package gen generates value types from structs marked //kanna:value. |
|
Package rule provides what the validate method of a //kanna:value type is written with: ready-made rules such as Email, and Fail and FailWith for rules of one's own.
|
Package rule provides what the validate method of a //kanna:value type is written with: ready-made rules such as Email, and Fail and FailWith for rules of one's own. |