diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b24bdd4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,145 @@ +--- +name: CI +on: [push] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + version: v1.42 + env: + VERBOSE: "true" + + tidy: + name: Tidy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + go-version: 1.15 + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Check if mods are tidy + run: make check-tidy + + # benchmark: + # name: Benchmarks + # runs-on: ubuntu-latest + # if: github.ref != 'refs/heads/main' + # steps: + # - uses: actions/checkout@v2 + # - uses: actions/setup-go@v2 + # with: + # go-version: 1.15 + # - uses: actions/cache@v2 + # with: + # path: ~/go/pkg/mod + # key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + # restore-keys: | + # ${{ runner.os }}-go- + # - name: Run benchmarks + # run: make bench | tee output.raw + # - name: Fix benchmark names + # run: >- + # perl -pe 's/^(Benchmark.+?)\/(\S+)(-\d+)(\s+)/\1__\2\4/' output.raw | + # tr '-' '_' | tee output.txt + # - name: Announce benchmark result + # uses: rhysd/github-action-benchmark@v1 + # with: + # tool: "go" + # output-file-path: output.txt + # fail-on-alert: true + # comment-on-alert: true + # github-token: ${{ secrets.GITHUB_TOKEN }} + # auto-push: false + + # cov: + # name: Coverage + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v2 + # - uses: actions/setup-go@v2 + # with: + # go-version: 1.15 + # - uses: actions/cache@v2 + # with: + # path: ~/go/pkg/mod + # key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + # restore-keys: | + # ${{ runner.os }}-go- + # - name: Publish coverage + # uses: paambaati/codeclimate-action@v2.7.4 + # env: + # VERBOSE: "true" + # GOMAXPROCS: 4 + # CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} + # with: + # coverageCommand: make cov + # prefix: github.com/${{ github.repository }} + # coverageLocations: | + # ${{ github.workspace }}/coverage.out:gocov + + test: + name: Test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go_version: + - "1.15" + - "1.16" + - "1.17" + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.terraform_version }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run tests + run: make test + env: + VERBOSE: "true" + # benchmark-store: + # name: Store benchmarks + # runs-on: ubuntu-latest + # if: github.ref == 'refs/heads/main' + # steps: + # - uses: actions/checkout@v2 + # - uses: actions/setup-go@v2 + # with: + # go-version: 1.15 + # - uses: actions/cache@v2 + # with: + # path: ~/go/pkg/mod + # key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + # restore-keys: | + # ${{ runner.os }}-go- + # - name: Run benchmarks + # run: make bench | tee output.raw + # - name: Fix benchmark names + # run: >- + # perl -pe 's/^(Benchmark.+?)\/(\S+)(-\d+)(\s+)/\1__\2\4/' output.raw | + # tr '-' '_' | tee output.txt + # - name: Store benchmark result + # uses: rhysd/github-action-benchmark@v1 + # with: + # tool: "go" + # output-file-path: output.txt + # github-token: ${{ secrets.ROMDOBOT_TOKEN }} + # comment-on-alert: true + # auto-push: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2663299 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/*.tidy-check +/bin/* +/coverage.out +/output.txt diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..53035e6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,99 @@ +linters-settings: + funlen: + lines: 100 + statements: 150 + goconst: + min-occurrences: 5 + gocyclo: + min-complexity: 20 + golint: + min-confidence: 0 + govet: + check-shadowing: true + enable-all: true + disable: + - fieldalignment + lll: + line-length: 80 + tab-width: 4 + maligned: + suggest-new: true + misspell: + locale: US + +linters: + disable-all: true + enable: + - asciicheck + - bodyclose + - deadcode + - depguard + - dupl + - durationcheck + - errcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - godot + - gofumpt + - goimports + - goprintffuncname + - gosec + - gosimple + - govet + - importas + - ineffassign + - lll + - misspell + - nakedret + - nilerr + - noctx + - nolintlint + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - structcheck + - tparallel + - typecheck + - unconvert + - unparam + - unused + - varcheck + - wastedassign + - whitespace + +issues: + exclude: + - Using the variable on range scope `tt` in function literal + - Using the variable on range scope `tc` in function literal + exclude-rules: + - path: "_test\\.go" + linters: + - funlen + - dupl + - goconst + - source: "^//go:generate " + linters: + - lll + - source: "`json:" + linters: + - lll + - source: "`yaml:" + linters: + - lll + - source: "`form:" + linters: + - lll + +run: + timeout: 2m + allow-parallel-runners: true + modules-download-mode: readonly diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..13e2870 --- /dev/null +++ b/Makefile @@ -0,0 +1,192 @@ +GOMODNAME := $(shell grep 'module' go.mod | sed -e 's/^module //') +SOURCES := $(shell find . -name "*.go" -or -name "go.mod" -or -name "go.sum" \ + -or -name "Makefile") + +# Verbose output +ifdef VERBOSE +V = -v +endif + +# +# Environment +# + +BINDIR := bin +TOOLDIR := $(BINDIR)/tools + +# Global environment variables for all targets +SHELL ?= /bin/bash +SHELL := env \ + GO111MODULE=on \ + GOBIN=$(CURDIR)/$(TOOLDIR) \ + CGO_ENABLED=1 \ + PATH='$(CURDIR)/$(BINDIR):$(CURDIR)/$(TOOLDIR):$(PATH)' \ + $(SHELL) + +# +# Defaults +# + +# Default target +.DEFAULT_GOAL := test + +# +# Tools +# + +TOOLS += $(TOOLDIR)/gobin +$(TOOLDIR)/gobin: + GO111MODULE=off go get -u github.com/myitcv/gobin + +# external tool +define tool # 1: binary-name, 2: go-import-path +TOOLS += $(TOOLDIR)/$(1) + +$(TOOLDIR)/$(1): $(TOOLDIR)/gobin Makefile + gobin $(V) "$(2)" +endef + +$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc)) +$(eval $(call tool,gofumports,mvdan.cc/gofumpt/gofumports)) +$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.42)) +$(eval $(call tool,gomod,github.com/Helcaraxan/gomod)) + +.PHONY: tools +tools: $(TOOLS) + +# +# Development +# + +BENCH ?= . +TESTARGS ?= + +.PHONY: clean +clean: + rm -f $(TOOLS) + rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check + +.PHONY: test +test: + go test $(V) -count=1 -race $(TESTARGS) ./... + +.PHONY: test-deps +test-deps: + go test all + +.PHONY: lint +lint: $(TOOLDIR)/golangci-lint + golangci-lint $(V) run + +.PHONY: format +format: $(TOOLDIR)/gofumports + gofumports -w . + +.SILENT: bench +.PHONY: bench +bench: + go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) ./... + +# +# Code Generation +# + +.PHONY: generate +generate: + go generate ./... + +.PHONY: check-generate +check-generate: + $(eval CHKDIR := $(shell mktemp -d)) + cp -av . "$(CHKDIR)" + make -C "$(CHKDIR)/" generate + ( diff -rN . "$(CHKDIR)" && rm -rf "$(CHKDIR)" ) || \ + ( rm -rf "$(CHKDIR)" && exit 1 ) + +# +# Coverage +# + +.PHONY: cov +cov: coverage.out + +.PHONY: cov-html +cov-html: coverage.out + go tool cover -html=./coverage.out + +.PHONY: cov-func +cov-func: coverage.out + go tool cover -func=./coverage.out + +coverage.out: $(SOURCES) + go test $(V) -covermode=count -coverprofile=./coverage.out ./... + +# +# Dependencies +# + +.PHONY: deps +deps: + go mod download + +.PHONY: deps-update +deps-update: + go get -u -t ./... + +.PHONY: deps-analyze +deps-analyze: $(TOOLDIR)/gomod + gomod analyze + +.PHONY: tidy +tidy: + go mod tidy $(V) + +.PHONY: verify +verify: + go mod verify + +.SILENT: check-tidy +.PHONY: check-tidy +check-tidy: + cp go.mod go.mod.tidy-check + cp go.sum go.sum.tidy-check + go mod tidy + ( \ + diff go.mod go.mod.tidy-check && \ + diff go.sum go.sum.tidy-check && \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum \ + ) || ( \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum; \ + exit 1 \ + ) + +# +# Documentation +# + +# Serve docs +.PHONY: docs +docs: $(TOOLDIR)/godoc + $(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/) + @godoc -http=127.0.0.1:6060 + +# +# Release +# + +.PHONY: new-version +new-version: check-npx + npx standard-version + +.PHONY: next-version +next-version: check-npx + npx standard-version --dry-run + +.PHONY: check-npx +check-npx: + $(if $(shell which npx),,\ + $(error No npx found in PATH, please install NodeJS)) diff --git a/README.md b/README.md index 62ef36c..c1c5bf9 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,75 @@
- [WIP] Go package for object validation with a goal of simple and flexible. + Yet another Go struct/object validation package, with a focus on simplicity, + flexibility, and full control over validation logic.
-## License +--- + +Add validation to any type, by simply implementing the `Validatable` interface: + +```go +type Validatable interface { + Validate() error +} +``` + +## Import + +```go +import "github.com/romdo/go-validate" +``` + +## Example + +```go +type Order struct { + Books []*Book `json:"books"` +} + +type Book struct { + Title string `json:"title"` + Author string `json:"author"` +} + +func (s *Book) Validate() error { + var errs error + if s.Title == "" { + errs = validate.Append(errs, &validate.Error{ + Field: "Title", Msg: "is required", + }) + } + + // Helper to perform the same kind of check as above for Title. + errs = validate.Append(errs, validate.RequireField("Author", s.Author)) + + return errs +} + +func main() { + errs := validate.Validate(&Order{Books: []*Book{{Title: ""}}}) + + for _, err := range validate.Errors(errs) { + fmt.Println(err.Error()) + } +} +``` + +Above example produces the following output: + +``` +books.0.title: is required +books.0.author: is required +``` + +## Documentation + +Please see the +[Go Reference](https://pkg.go.dev/github.com/romdo/go-validate#section-documentation) +for documentation and examples. + +## LICENSE [MIT](https://github.com/romdo/go-conventionalcommit/blob/main/LICENSE) diff --git a/error.go b/error.go new file mode 100644 index 0000000..86a6a7e --- /dev/null +++ b/error.go @@ -0,0 +1,67 @@ +package validate + +import ( + "errors" + "fmt" + + "go.uber.org/multierr" +) + +// Error represents validation errors, and implements Go's error type. Field +// indicates the struct field the validation error is relevant to, which is the +// full nested path relative to the top-level object being validated. +type Error struct { + Field string + Msg string + Err error +} + +func (s *Error) Error() string { + msg := s.Msg + if msg == "" && s.Err != nil { + msg = s.Err.Error() + } + + if msg == "" { + msg = "unknown error" + } + + if s.Field == "" { + return msg + } + + return fmt.Sprintf("%s: %s", s.Field, msg) +} + +func (s *Error) Is(target error) bool { + return errors.Is(s.Err, target) +} + +func (s *Error) Unwrap() error { + return s.Err +} + +// Append combines two errors together into a single new error which internally +// keeps track of multiple errors via go.uber.org/multierr. If either error is a +// previously combined multierr, the returned error will be a flattened list of +// all errors. +func Append(errs error, err error) error { + return multierr.Append(errs, err) +} + +// AppendError appends a new *Error type to errs with the Msg field populated +// with the provided msg. +func AppendError(errs error, msg string) error { + return multierr.Append(errs, &Error{Msg: msg}) +} + +// AppendFieldError appends a new *Error type to errs with Field and Msg +// populated with given field and msg values. +func AppendFieldError(errs error, field, msg string) error { + return multierr.Append(errs, &Error{Field: field, Msg: msg}) +} + +// Errors returns a slice of all errors appended into the given error. +func Errors(err error) []error { + return multierr.Errors(err) +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..1f0d6ab --- /dev/null +++ b/error_test.go @@ -0,0 +1,456 @@ +package validate + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/multierr" +) + +func TestError_Error(t *testing.T) { + type fields struct { + Field string + Msg string + Err error + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "empty", + fields: fields{}, + want: "unknown error", + }, + { + name: "field only", + fields: fields{ + Field: "spec.images.0.name", + }, + want: "spec.images.0.name: unknown error", + }, + { + name: "msg only", + fields: fields{ + Msg: "flux capacitor is missing", + }, + want: "flux capacitor is missing", + }, + { + name: "err only", + fields: fields{ + Err: errors.New("flux capacitor is king"), + }, + want: "flux capacitor is king", + }, + { + name: "field and msg", + fields: fields{ + Field: "spec.images.0.name", + Msg: "is required", + }, + want: "spec.images.0.name: is required", + }, + { + name: "field and err", + fields: fields{ + Field: "spec", + Err: errors.New("something is wrong"), + }, + want: "spec: something is wrong", + }, + { + name: "msg and err", + fields: fields{ + Msg: "flux capacitor is missing", + Err: errors.New("flux capacitor is king"), + }, + want: "flux capacitor is missing", + }, + { + name: "field, msg, and err", + fields: fields{ + Field: "spec.images.0.name", + Msg: "is required", + Err: errors.New("something is wrong"), + }, + want: "spec.images.0.name: is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &Error{ + Field: tt.fields.Field, + Msg: tt.fields.Msg, + Err: tt.fields.Err, + } + + got := err.Error() + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestError_Is(t *testing.T) { + errTest1 := errors.New("errtest1") + errTest2 := errors.New("errtest2") + + type fields struct { + Err error + } + tests := []struct { + name string + fields fields + target error + want bool + }{ + { + name: "empty", + fields: fields{}, + target: errTest1, + want: false, + }, + { + name: "Err and target match", + fields: fields{Err: errTest1}, + target: errTest1, + want: true, + }, + { + name: "Err and target do not match", + fields: fields{Err: errTest2}, + target: errTest1, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &Error{Err: tt.fields.Err} + + got := errors.Is(err, tt.target) + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestError_Unwrap(t *testing.T) { + errTest1 := errors.New("errtest1") + errTest2 := errors.New("errtest2") + + type fields struct { + Err error + } + tests := []struct { + name string + fields fields + want error + }{ + { + name: "empty", + fields: fields{}, + want: nil, + }, + { + name: "Err test1", + fields: fields{Err: errTest1}, + want: errTest1, + }, + { + name: "Err test2", + fields: fields{Err: errTest2}, + want: errTest2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &Error{Err: tt.fields.Err} + + got := err.Unwrap() + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestAppend(t *testing.T) { + type args struct { + errs error + err error + } + tests := []struct { + name string + args args + want []error + }{ + { + name: "append nil to nil", + args: args{ + errs: nil, + err: nil, + }, + want: []error{}, + }, + { + name: "append nil to err", + args: args{ + errs: errors.New("foo"), + err: nil, + }, + want: []error{ + errors.New("foo"), + }, + }, + { + name: "append nil to multi err", + args: args{ + errs: multierr.Combine(errors.New("foo"), errors.New("bar")), + err: nil, + }, + want: []error{ + errors.New("foo"), + errors.New("bar"), + }, + }, + { + name: "append err to nil", + args: args{ + errs: nil, + err: errors.New("foo"), + }, + want: []error{ + errors.New("foo"), + }, + }, + { + name: "append err to err", + args: args{ + errs: errors.New("foo"), + err: errors.New("bar"), + }, + want: []error{ + errors.New("foo"), + errors.New("bar"), + }, + }, + { + name: "append err to multi err", + args: args{ + errs: multierr.Combine(errors.New("foo"), errors.New("bar")), + err: errors.New("baz"), + }, + want: []error{ + errors.New("foo"), + errors.New("bar"), + errors.New("baz"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Append(tt.args.errs, tt.args.err) + + if len(tt.want) == 0 { + assert.Nil(t, err) + } + + got := multierr.Errors(err) + + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestAppendError(t *testing.T) { + type args struct { + errs error + msg string + } + tests := []struct { + name string + args args + want []error + }{ + { + name: "append empty msg to nil", + args: args{ + errs: nil, + msg: "", + }, + want: []error{ + &Error{}, + }, + }, + { + name: "append msg to nil", + args: args{ + errs: nil, + msg: "foo", + }, + want: []error{ + &Error{Msg: "foo"}, + }, + }, + { + name: "append msg to err", + args: args{ + errs: errors.New("foo"), + msg: "bar", + }, + want: []error{ + errors.New("foo"), + &Error{Msg: "bar"}, + }, + }, + { + name: "append msg to multi err", + args: args{ + errs: multierr.Combine(errors.New("foo"), errors.New("bar")), + msg: "baz", + }, + want: []error{ + errors.New("foo"), + errors.New("bar"), + &Error{Msg: "baz"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := AppendError(tt.args.errs, tt.args.msg) + + if len(tt.want) == 0 { + assert.Nil(t, err) + } + + got := multierr.Errors(err) + + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestAppendFieldError(t *testing.T) { + type args struct { + errs error + field string + msg string + } + tests := []struct { + name string + args args + want []error + }{ + { + name: "append empty field and msg to nil", + args: args{ + errs: nil, + field: "", + msg: "", + }, + want: []error{ + &Error{}, + }, + }, + { + name: "append msg to nil", + args: args{ + errs: nil, + field: "Type", + msg: "foo", + }, + want: []error{ + &Error{Field: "Type", Msg: "foo"}, + }, + }, + { + name: "append msg to err", + args: args{ + errs: errors.New("foo"), + field: "Type", + msg: "bar", + }, + want: []error{ + errors.New("foo"), + &Error{Field: "Type", Msg: "bar"}, + }, + }, + { + name: "append msg to multi err", + args: args{ + errs: multierr.Combine(errors.New("foo"), errors.New("bar")), + field: "Type", + msg: "baz", + }, + want: []error{ + errors.New("foo"), + errors.New("bar"), + &Error{Field: "Type", Msg: "baz"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := AppendFieldError(tt.args.errs, tt.args.field, tt.args.msg) + + if len(tt.want) == 0 { + assert.Nil(t, err) + } + + got := multierr.Errors(err) + + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestErrors(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args args + want []error + }{ + { + name: "nil", + args: args{err: nil}, + want: nil, + }, + { + name: "single error", + args: args{err: errors.New("foo")}, + want: []error{errors.New("foo")}, + }, + { + name: "multi error with one error", + args: args{ + err: multierr.Combine(errors.New("foo")), + }, + want: []error{ + errors.New("foo"), + }, + }, + { + name: "multi error with two errors", + args: args{ + err: multierr.Combine(errors.New("foo"), errors.New("bar")), + }, + want: []error{ + errors.New("foo"), + errors.New("bar"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Errors(tt.args.err) + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/examples/basic/basic.go b/examples/basic/basic.go new file mode 100644 index 0000000..de61878 --- /dev/null +++ b/examples/basic/basic.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + + "github.com/romdo/go-validate" +) + +type Order struct { + Books []*Book `json:"books"` +} + +type Book struct { + Title string `json:"title"` + Author string `json:"author"` +} + +func (s *Book) Validate() error { + var errs error + if s.Title == "" { + errs = validate.Append(errs, &validate.Error{ + Field: "Title", Msg: "is required", + }) + } + + // Helper to perform the same kind of check as above for Title. + errs = validate.Append(errs, validate.RequireField("Author", s.Author)) + + return errs +} + +func main() { + errs := validate.Validate(&Order{Books: []*Book{{Title: ""}}}) + + for _, err := range validate.Errors(errs) { + fmt.Println(err.Error()) + } +} diff --git a/examples/complex/complex.go b/examples/complex/complex.go new file mode 100644 index 0000000..21cac7b --- /dev/null +++ b/examples/complex/complex.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + + "github.com/romdo/go-validate" +) + +type Manifest struct { + Spec *Spec `json:"spec"` +} + +func (s *Manifest) Validate() error { + return validate.RequireField("Spec", s.Spec) +} + +type Spec struct { + Containers []*Container `json:"containers"` + Images []*Image `json:"images"` +} + +func (s *Spec) Validate() error { + var errs error + + if len(s.Containers) == 0 { + errs = validate.AppendFieldError(errs, + "Containers", "must contain at least one item", + ) + } else { + imgs := map[string]bool{} + for _, img := range s.Images { + if img.Name != "" { + imgs[img.Name] = true + } + } + for i, c := range s.Containers { + if c.ImageRef != "" && !imgs[c.ImageRef] { + errs = validate.Append(errs, &validate.Error{ + Field: fmt.Sprintf("containers.%d.imageRef", i), + Msg: fmt.Sprintf( + "image with name '%s' not found", c.ImageRef, + ), + }) + } + } + } + + if len(s.Images) == 0 { + errs = validate.AppendFieldError(errs, + "Images", "must contain at least one item", + ) + } + + return errs +} + +type Container struct { + Name string `json:"name"` + ImageRef string `json:"imageRef"` +} + +func (s *Container) Validate() error { + var errs error + errs = validate.Append(errs, validate.RequireField("Name", s.Name)) + errs = validate.Append(errs, validate.RequireField("ImageRef", s.ImageRef)) + + return errs +} + +type Image struct { + Name string `json:"name"` + URI string `json:"uri"` + Tag string `json:"tag"` +} + +func (s *Image) Validate() error { + var errs error + errs = validate.Append(errs, validate.RequireField("Name", s.Name)) + errs = validate.Append(errs, validate.RequireField("URI", s.URI)) + errs = validate.Append(errs, validate.RequireField("Tag", s.Tag)) + + return errs +} + +func main() { + manifest := &Manifest{ + Spec: &Spec{ + Containers: []*Container{ + { + ImageRef: "server", + }, + { + Name: "worker", + ImageRef: "myServer", + }, + }, + Images: []*Image{ + { + Name: "server", + }, + }, + }, + } + + errs := validate.Validate(manifest) + + for _, err := range validate.Errors(errs) { + fmt.Println(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..91f9554 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/romdo/go-validate + +go 1.15 + +require ( + github.com/stretchr/testify v1.7.0 + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.7.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..08dcf93 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..a58f375 --- /dev/null +++ b/helpers.go @@ -0,0 +1,36 @@ +package validate + +import ( + "reflect" +) + +// RequireField returns a Error type for the given field if provided value is +// empty/zero. +func RequireField(field string, value interface{}) error { + err := &Error{Field: field, Msg: "is required"} + v := reflect.ValueOf(value) + + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return err + } + v = v.Elem() + } + + if !v.IsValid() { + return err + } + + switch v.Kind() { //nolint:exhaustive + case reflect.Map, reflect.Slice: + if v.Len() == 0 { + return err + } + default: + if v.IsZero() { + return err + } + } + + return nil +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..b15461c --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,406 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func stringPtr(s string) *string { + return &s +} + +func TestRequireField(t *testing.T) { + var nilMapString map[string]string + emptyMapString := map[string]string{} + mapString := map[string]string{"foo": "bar"} + type testStruct struct { + Name string + } + + type args struct { + field string + value interface{} + } + tests := []struct { + name string + args args + want error + }{ + { + name: "nil", + args: args{ + field: "Title", + value: nil, + }, + want: &Error{Field: "Title", Msg: "is required"}, + }, + { + name: "nil pointer", + args: args{ + field: "Title", + value: &nilMapString, + }, + want: &Error{Field: "Title", Msg: "is required"}, + }, + { + name: "true boolean", + args: args{ + field: "Book", + value: true, + }, + want: nil, + }, + { + name: "false boolean", + args: args{ + field: "Book", + value: false, + }, + want: &Error{Field: "Book", Msg: "is required"}, + }, + { + name: "int", + args: args{ + field: "Count", + value: int(834), + }, + want: nil, + }, + { + name: "zero int", + args: args{ + field: "Count", + value: int(0), + }, + want: &Error{Field: "Count", Msg: "is required"}, + }, + { + name: "int8", + args: args{ + field: "Ticks", + value: int8(3), + }, + want: nil, + }, + { + name: "zero int8", + args: args{ + field: "Ticks", + value: int8(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "int16", + args: args{ + field: "Ticks", + value: int16(3), + }, + want: nil, + }, + { + name: "zero int16", + args: args{ + field: "Ticks", + value: int16(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "int32", + args: args{ + field: "Ticks", + value: int32(3), + }, + want: nil, + }, + { + name: "zero int32", + args: args{ + field: "Ticks", + value: int32(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "int64", + args: args{ + field: "Ticks", + value: int64(3), + }, + want: nil, + }, + { + name: "zero int64", + args: args{ + field: "Ticks", + value: int64(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "zero uint", + args: args{ + field: "Count", + value: uint(0), + }, + want: &Error{Field: "Count", Msg: "is required"}, + }, + { + name: "uint8", + args: args{ + field: "Ticks", + value: uint8(3), + }, + want: nil, + }, + { + name: "zero uint8", + args: args{ + field: "Ticks", + value: uint8(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "uint16", + args: args{ + field: "Ticks", + value: uint16(3), + }, + want: nil, + }, + { + name: "zero uint16", + args: args{ + field: "Ticks", + value: uint16(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "uint32", + args: args{ + field: "Ticks", + value: uint32(3), + }, + want: nil, + }, + { + name: "zero uint32", + args: args{ + field: "Ticks", + value: uint32(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "uint64", + args: args{ + field: "Ticks", + value: uint64(3), + }, + want: nil, + }, + { + name: "zero uint64", + args: args{ + field: "Ticks", + value: uint64(0), + }, + want: &Error{Field: "Ticks", Msg: "is required"}, + }, + { + name: "complex64", + args: args{ + field: "Offset", + value: complex64(3), + }, + want: nil, + }, + { + name: "zero complex64", + args: args{ + field: "Offset", + value: complex64(0), + }, + want: &Error{Field: "Offset", Msg: "is required"}, + }, + { + name: "complex128", + args: args{ + field: "Offset", + value: complex128(3), + }, + want: nil, + }, + { + name: "zero complex128", + args: args{ + field: "Offset", + value: complex128(0), + }, + want: &Error{Field: "Offset", Msg: "is required"}, + }, + { + name: "array", + args: args{ + field: "List", + value: [3]string{"foo", "bar", "baz"}, + }, + want: nil, + }, + { + name: "empty array", + args: args{ + field: "List", + value: [3]string{}, + }, + want: &Error{Field: "List", Msg: "is required"}, + }, + { + name: "chan", + args: args{ + field: "Comms", + value: make(chan int), + }, + want: nil, + }, + { + name: "func", + args: args{ + field: "Callback", + value: func() error { return nil }, + }, + want: nil, + }, + { + name: "map", + args: args{ + field: "Lookup", + value: map[string]string{"foo": "bar"}, + }, + want: nil, + }, + { + name: "map pointer", + args: args{ + field: "Lookup", + value: &mapString, + }, + want: nil, + }, + { + name: "empty map", + args: args{ + field: "Lookup", + value: map[string]string{}, + }, + want: &Error{Field: "Lookup", Msg: "is required"}, + }, + { + name: "empty map pointer", + args: args{ + field: "Lookup", + value: &emptyMapString, + }, + want: &Error{Field: "Lookup", Msg: "is required"}, + }, + { + name: "nil map", + args: args{ + field: "Lookup", + value: nilMapString, + }, + want: &Error{Field: "Lookup", Msg: "is required"}, + }, + { + name: "slice", + args: args{ + field: "List", + value: []string{"foo", "bar", "baz"}, + }, + want: nil, + }, + { + name: "empty slice", + args: args{ + field: "List", + value: []string{}, + }, + want: &Error{Field: "List", Msg: "is required"}, + }, + { + name: "string", + args: args{ + field: "Book", + value: "foo", + }, + want: nil, + }, + { + name: "string pointer", + args: args{ + field: "Book", + value: stringPtr("foo"), + }, + want: nil, + }, + { + name: "empty string", + args: args{ + field: "Book", + value: "", + }, + want: &Error{Field: "Book", Msg: "is required"}, + }, + { + name: "empty string pointer", + args: args{ + field: "Book", + value: stringPtr(""), + }, + want: &Error{Field: "Book", Msg: "is required"}, + }, + { + name: "struct", + args: args{ + field: "Thing", + value: testStruct{Name: "hi"}, + }, + want: nil, + }, + { + name: "struct pointer", + args: args{ + field: "Thing", + value: &testStruct{Name: "hi"}, + }, + want: nil, + }, + { + name: "empty struct", + args: args{ + field: "Thing", + value: testStruct{}, + }, + want: &Error{Field: "Thing", Msg: "is required"}, + }, + { + name: "empty struct pointer", + args: args{ + field: "Thing", + value: &testStruct{}, + }, + want: &Error{Field: "Thing", Msg: "is required"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RequireField(tt.args.field, tt.args.value) + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..45f65ea --- /dev/null +++ b/validate.go @@ -0,0 +1,162 @@ +// Package validate is yet another Go struct/object validation package, with a +// focus on simplicity, flexibility, and full control over validation logic. +// +// Interface +// +// To add validation to any type, simply implement the Validatable interface: +// +// type Validatable interface { +// Validate() error +// } +// +// To mark a object as failing validation, the Validate method simply needs to +// return a error. +// +// When validating array, slice, map, and struct types each item and/or field +// that implements Validatable will be validated, meaning deeply nested structs +// can be fully validated, and the nested path to each object is tracked and +// reported back any validation errors. +// +// Multiple Errors +// +// Multiple errors can be reported from the Validate method using one of the +// available Append helper functions which append errors together. Under the +// hood the go.uber.org/multierr package is used to represent multiple errors as +// a single error return type, and you can in fact just directly use multierr in +// the a type's Validate method. +// +// Structs and Field-specific Errors +// +// When validating a struct, you are likely to have multiple errors for multiple +// fields. To specify which field on the struct the error relates to, you have +// to return a *validate.Error instead of a normal Go error type. For example: +// +// type Book struct { +// Title string +// Author string +// } +// +// func (s *Book) Validate() error { +// var errs error +// +// if s.Title == "" { +// errs = validate.Append(errs, &validate.Error{ +// Field: "Title", Msg: "is required", +// }) +// } +// +// if s.Author == "" { +// // Yields the same result as the Title field check above. +// errs = validate.AppendFieldError(errs, "Author", "is required") +// } +// +// return errs +// } +// +// With the above example, if you validate a empty *Book: +// +// err := validate.Validate(&Book{}) +// for _, e := range validate.Errors(err) { +// fmt.Println(e.Error()) +// } +// +// The following errors would be printed: +// +// Title: is required +// Kind: is required +// +// Error type +// +// All errors will be wrapped in a *Error before being returned, which is used +// to keep track of the path and field the error relates to. There are various +// helpers available to create Error instances. +// +// Handling Validation Errors +// +// As mentioned above, multiple errors are wrapped up into a single error return +// value using go.uber.org/multierr. You can access all errors individually with +// Errors(), which accepts a single error, and returns []error. The Errors() +// function is just wrapper around multierr.Errors(), so you could use that +// instead if you prefer. +// +// Struct Field Tags +// +// Fields on a struct which customize the name via a json, yaml, or form field +// tag, will automatically have the field name converted to the name in the tag +// in returned *Error types with a non-empty Field value. +// +// You can customize the field name conversion logic by creating a custom +// Validator instance, and calling FieldNameFunc() on it. +// +// Nested Validatable Objects +// +// All items/fields on any structs, maps, slices or arrays which are encountered +// will be validated if they implement the Validatable interface. While +// traversing nested data structures, a path list tracks the location of the +// current object being validation in relation to the top-level object being +// validated. This path is used within the field in the final output errors. +// +// By default path components are joined with a dot, but this can be customized +// when using a custom Validator instance and calling FieldJoinFunc() passing in +// a custom function to handle path joining. +// +// As an example, if our Book struct from above is nested within the following +// structs: +// +// type Order struct { +// Items []*Item `json:"items"` +// } +// +// type Item struct { +// Book *Book `json:"book"` +// } +// +// And we have a Order where the book in the second Item has a empty Author +// field: +// +// err := validate.Validate(&Order{ +// Items: []*Item{ +// {Book: &Book{Title: "The Traveler", Author: "John Twelve Hawks"}}, +// {Book: &Book{Title: "The Firm"}}, +// }, +// }) +// for _, e := range validate.Errors(err) { +// fmt.Println(e.Error()) +// } +// +// Then we would get the following error: +// +// items.1.book.Author: is required +// +// Note how both "items" and "book" are lower cased thanks to the json tags on +// the struct fields, while our Book struct does not have a json tag for the +// Author field. +// +// Also note that the error message does not start with "Order". The field path +// is relative to the object being validated, hence the top-level object is not +// part of the returned field path. +package validate + +// global is a private instance of Validator to enable the package root-level +// Validate() function. +var global = New() + +// Validate will validate the given object. Structs, maps, slices, and arrays +// will have each of their fields/items validated, effectively performing a +// deep-validation. +func Validate(v interface{}) error { + return global.Validate(v) +} + +// Validatable is the primary interface that a object needs to implement to be +// validatable with Validator. +// +// Validation errors are reported by returning a error from the Validate +// method. Multiple errors can be combined into a single error to return with +// Append() and related functions, or via go.uber.org/multierr. +// +// For validatable structs, the field the validation error relates to can be +// specified by returning a *Error type with the Field value specified. +type Validatable interface { + Validate() error +} diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..3108020 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,907 @@ +package validate_test + +import ( + "errors" + "strings" + "testing" + + "github.com/romdo/go-validate" + "github.com/stretchr/testify/assert" +) + +// +// Test helper types +// + +type validatableString string + +func (s validatableString) Validate() error { + if strings.Contains(string(s), " ") { + return &validate.Error{Msg: "must not contain space"} + } + + return nil +} + +type validatableStruct struct { + Foo string + Bar string `json:"bar"` + Foz string `yaml:"foz"` + Baz string `form:"baz"` + + f func() error +} + +func (s *validatableStruct) Validate() error { + if s.f == nil { + return nil + } + + return s.f() +} + +type nestedStruct struct { + OtherField *validatableStruct + OtherFieldJSON *validatableStruct `json:"other_field,omitempty"` + OtherFieldYAML *validatableStruct `yaml:"otherField,omitempty"` + OtherFieldFORM *validatableStruct `form:"other-field,omitempty"` + + skippedField *validatableStruct + SkippedFieldJSON *validatableStruct `json:"-,omitempty"` + SkippedFieldYAML *validatableStruct `yaml:"-,omitempty"` + SkippedFieldFORM *validatableStruct `form:"-,omitempty"` + + OtherArray [5]*validatableStruct + OtherArrayJSON [5]*validatableStruct `json:"other_array,omitempty"` + OtherArrayYAML [5]*validatableStruct `yaml:"otherArray,omitempty"` + OtherArrayFORM [5]*validatableStruct `form:"other-array,omitempty"` + + OtherSlice []*validatableStruct + OtherSliceJSON []*validatableStruct `json:"other_slice,omitempty"` + OtherSliceYAML []*validatableStruct `yaml:"otherSlice,omitempty"` + OtherSliceFORM []*validatableStruct `form:"other-slice,omitempty"` + + OtherStringMap map[string]*validatableStruct + OtherStringMapJSON map[string]*validatableStruct `json:"other_string_map,omitempty"` + OtherStringMapYAML map[string]*validatableStruct `yaml:"otherStringMap,omitempty"` + OtherStringMapFORM map[string]*validatableStruct `form:"other-string-map,omitempty"` + + OtherIntMap map[int]*validatableStruct + OtherIntMapJSON map[int]*validatableStruct `json:"other_int_map,omitempty"` + OtherIntMapYAML map[int]*validatableStruct `yaml:"otherIntMap,omitempty"` + OtherIntMapFORM map[int]*validatableStruct `form:"other-int-map,omitempty"` + + OtherStruct *nestedStruct + OtherStructJSON *nestedStruct `json:"other_struct,omitempty"` + OtherStructYAML *nestedStruct `yaml:"otherStruct,omitempty"` + OtherStructFORM *nestedStruct `form:"other-struct,omitempty"` +} + +// +// Tests +// + +func TestValidate(t *testing.T) { + mixedValidationErrors := &validatableStruct{ + f: func() error { + var errs error + errs = validate.Append(errs, &validate.Error{ + Field: "Foo", Msg: "is required", + Err: errors.New("oops"), + }) + errs = validate.Append(errs, errors.New("bar: is missing")) + + return errs + }, + } + + tests := []struct { + name string + obj interface{} + wantErrs []error + }{ + { + name: "nil", + obj: nil, + wantErrs: []error{}, + }, + { + name: "no error", + obj: &validatableStruct{}, + wantErrs: nil, + }, + { + name: "valid validatable string type", + obj: validatableString("hello-world"), + wantErrs: []error{}, + }, + { + name: "invalid validatable string type", + obj: validatableString("hello world"), + wantErrs: []error{ + &validate.Error{Msg: "must not contain space"}, + }, + }, + { + name: "single Go error", + obj: &validatableStruct{f: func() error { + return errors.New("foo: is required") + }}, + wantErrs: []error{ + &validate.Error{Err: errors.New("foo: is required")}, + }, + }, + { + name: "single *validate.Error", + obj: &validatableStruct{f: func() error { + return &validate.Error{ + Field: "foo", Msg: "is required", Err: errors.New("oops"), + } + }}, + wantErrs: []error{ + &validate.Error{ + Field: "foo", Msg: "is required", Err: errors.New("oops"), + }, + }, + }, + { + name: "multiple Go errors", + obj: &validatableStruct{f: func() error { + var errs error + errs = validate.Append(errs, errors.New("foo: is required")) + errs = validate.Append(errs, errors.New("bar: is missing")) + + return errs + }}, + wantErrs: []error{ + &validate.Error{Err: errors.New("foo: is required")}, + &validate.Error{Err: errors.New("bar: is missing")}, + }, + }, + { + name: "multiple *validate.Error", + obj: &validatableStruct{f: func() error { + var errs error + errs = validate.Append(errs, &validate.Error{ + Field: "foo", Msg: "is required", Err: errors.New("oops"), + }) + errs = validate.Append(errs, &validate.Error{ + Field: "bar", Msg: "is required", Err: errors.New("whoops"), + }) + + return errs + }}, + wantErrs: []error{ + &validate.Error{ + Field: "foo", Msg: "is required", Err: errors.New("oops"), + }, + &validate.Error{ + Field: "bar", Msg: "is required", Err: errors.New("whoops"), + }, + }, + }, + { + name: "mix of Go error and *validate.Error", + obj: mixedValidationErrors, + wantErrs: []error{ + &validate.Error{ + Field: "Foo", Msg: "is required", Err: errors.New("oops"), + }, + &validate.Error{Err: errors.New("bar: is missing")}, + }, + }, + // + // Field name conversion + // + { + name: "no json, yaml or form field tag", + obj: &validatableStruct{f: func() error { + return &validate.Error{Field: "Foo", Msg: "is required"} + }}, + wantErrs: []error{ + &validate.Error{Field: "Foo", Msg: "is required"}, + }, + }, + { + name: "converts field name via json field tag", + obj: &validatableStruct{f: func() error { + return &validate.Error{Field: "Bar", Msg: "is required"} + }}, + wantErrs: []error{ + &validate.Error{Field: "bar", Msg: "is required"}, + }, + }, + { + name: "converts field name via yaml field tag", + obj: &validatableStruct{f: func() error { + return &validate.Error{Field: "Foz", Msg: "is required"} + }}, + wantErrs: []error{ + &validate.Error{Field: "foz", Msg: "is required"}, + }, + }, + { + name: "converts field name via form field tag", + obj: &validatableStruct{f: func() error { + return &validate.Error{Field: "Baz", Msg: "is required"} + }}, + wantErrs: []error{ + &validate.Error{Field: "baz", Msg: "is required"}, + }, + }, + { + name: "nested with no validation errors", + obj: &nestedStruct{ + OtherField: &validatableStruct{}, + OtherArray: [5]*validatableStruct{{}, {}, {}, {}}, + OtherSlice: []*validatableStruct{{}}, + OtherStringMap: map[string]*validatableStruct{ + "hi": {}, + "bye": {}, + }, + OtherIntMap: map[int]*validatableStruct{42: {}, 64: {}}, + OtherStruct: &nestedStruct{ + OtherField: &validatableStruct{}, + }, + }, + wantErrs: []error{}, + }, + // + // Nested in a struct field. + // + { + name: "nested in a struct field", + obj: &nestedStruct{ + OtherField: mixedValidationErrors, + }, + wantErrs: []error{ + &validate.Error{ + Field: "OtherField.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherField", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a struct field with json tag", + obj: &nestedStruct{ + OtherFieldJSON: mixedValidationErrors, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other_field.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_field", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a struct field with yaml tag", + obj: &nestedStruct{ + OtherFieldYAML: mixedValidationErrors, + }, + wantErrs: []error{ + &validate.Error{ + Field: "otherField.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherField", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a struct field with form tag", + obj: &nestedStruct{ + OtherFieldFORM: mixedValidationErrors, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other-field.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-field", Err: errors.New("bar: is missing"), + }, + }, + }, + // + // Nested in a unexposed/ignored fields. + // + { + name: "nested in a unexposed field", + obj: &nestedStruct{ + skippedField: mixedValidationErrors, + }, + wantErrs: []error{}, + }, + { + name: "nested in a struct field skipped by json tag", + obj: &nestedStruct{ + SkippedFieldJSON: mixedValidationErrors, + }, + wantErrs: []error{}, + }, + { + name: "nested in a struct field skipped by yaml tag", + obj: &nestedStruct{ + SkippedFieldYAML: mixedValidationErrors, + }, + wantErrs: []error{}, + }, + { + name: "nested in a struct field skipped by yaml tag", + obj: &nestedStruct{ + SkippedFieldFORM: mixedValidationErrors, + }, + wantErrs: []error{}, + }, + // + // Nested in an array. + // + { + name: "nested in an array", + obj: &nestedStruct{ + OtherArray: [5]*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "OtherArray.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherArray.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "OtherArray.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherArray.1", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in an array with json tag", + obj: &nestedStruct{ + OtherArrayJSON: [5]*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other_array.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_array.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other_array.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_array.1", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in an array with yaml tag", + obj: &nestedStruct{ + OtherArrayYAML: [5]*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "otherArray.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherArray.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "otherArray.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherArray.1", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in an array with form tag", + obj: &nestedStruct{ + OtherArrayFORM: [5]*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other-array.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-array.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other-array.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-array.1", Err: errors.New("bar: is missing"), + }, + }, + }, + // + // Nested in a slice. + // + { + name: "nested in a slice", + obj: &nestedStruct{ + OtherSlice: []*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "OtherSlice.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherSlice.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "OtherSlice.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherSlice.1", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a slice with json tag", + obj: &nestedStruct{ + OtherSliceJSON: []*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other_slice.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_slice.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other_slice.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_slice.1", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a slice with yaml tag", + obj: &nestedStruct{ + OtherSliceYAML: []*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "otherSlice.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherSlice.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "otherSlice.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherSlice.1", Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a slice with form tag", + obj: &nestedStruct{ + OtherSliceFORM: []*validatableStruct{ + mixedValidationErrors, + mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other-slice.0.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-slice.0", Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other-slice.1.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-slice.1", Err: errors.New("bar: is missing"), + }, + }, + }, + // + // Nested in an string map. + // + { + name: "nested in a string map", + obj: &nestedStruct{ + OtherStringMap: map[string]*validatableStruct{ + "hello": mixedValidationErrors, + "world": mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "OtherStringMap.hello.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherStringMap.hello", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "OtherStringMap.world.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherStringMap.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a string map with json tag", + obj: &nestedStruct{ + OtherStringMapJSON: map[string]*validatableStruct{ + "hello": mixedValidationErrors, + "world": mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other_string_map.hello.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_string_map.hello", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other_string_map.world.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_string_map.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a string map with yaml tag", + obj: &nestedStruct{ + OtherStringMapYAML: map[string]*validatableStruct{ + "hello": mixedValidationErrors, + "world": mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "otherStringMap.hello.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherStringMap.hello", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "otherStringMap.world.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherStringMap.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a string map with form tag", + obj: &nestedStruct{ + OtherStringMapFORM: map[string]*validatableStruct{ + "hello": mixedValidationErrors, + "world": mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other-string-map.hello.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-string-map.hello", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other-string-map.world.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-string-map.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + // + // Nested in an int map. + // + { + name: "nested in a int map", + obj: &nestedStruct{ + OtherIntMap: map[int]*validatableStruct{ + 42: mixedValidationErrors, + 64: mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "OtherIntMap.42.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherIntMap.42", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "OtherIntMap.64.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherIntMap.64", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a int map with json tag", + obj: &nestedStruct{ + OtherIntMapJSON: map[int]*validatableStruct{ + 42: mixedValidationErrors, + 64: mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other_int_map.42.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_int_map.42", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other_int_map.64.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_int_map.64", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a int map with yaml tag", + obj: &nestedStruct{ + OtherIntMapYAML: map[int]*validatableStruct{ + 42: mixedValidationErrors, + 64: mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "otherIntMap.42.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherIntMap.42", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "otherIntMap.64.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherIntMap.64", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a int map with form tag", + obj: &nestedStruct{ + OtherIntMapFORM: map[int]*validatableStruct{ + 42: mixedValidationErrors, + 64: mixedValidationErrors, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other-int-map.42.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-int-map.42", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other-int-map.64.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-int-map.64", + Err: errors.New("bar: is missing"), + }, + }, + }, + // + // Nested in another struct. + // + { + name: "nested in another struct", + obj: &nestedStruct{ + OtherStruct: &nestedStruct{ + OtherField: mixedValidationErrors, + OtherStringMap: map[string]*validatableStruct{ + "world": mixedValidationErrors, + }, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "OtherStruct.OtherField.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherStruct.OtherField", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "OtherStruct.OtherStringMap.world.Foo", + Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "OtherStruct.OtherStringMap.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a int map with json tag", + obj: &nestedStruct{ + OtherStructJSON: &nestedStruct{ + OtherFieldJSON: mixedValidationErrors, + OtherStringMapJSON: map[string]*validatableStruct{ + "world": mixedValidationErrors, + }, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other_struct.other_field.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_struct.other_field", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other_struct.other_string_map.world.Foo", + Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other_struct.other_string_map.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a int map with yaml tag", + obj: &nestedStruct{ + OtherStructYAML: &nestedStruct{ + OtherFieldYAML: mixedValidationErrors, + OtherStringMapYAML: map[string]*validatableStruct{ + "world": mixedValidationErrors, + }, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "otherStruct.otherField.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherStruct.otherField", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "otherStruct.otherStringMap.world.Foo", + Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "otherStruct.otherStringMap.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + { + name: "nested in a int map with form tag", + obj: &nestedStruct{ + OtherStructFORM: &nestedStruct{ + OtherFieldFORM: mixedValidationErrors, + OtherStringMapFORM: map[string]*validatableStruct{ + "world": mixedValidationErrors, + }, + }, + }, + wantErrs: []error{ + &validate.Error{ + Field: "other-struct.other-field.Foo", Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-struct.other-field", + Err: errors.New("bar: is missing"), + }, + &validate.Error{ + Field: "other-struct.other-string-map.world.Foo", + Msg: "is required", + Err: errors.New("oops"), + }, + &validate.Error{ + Field: "other-struct.other-string-map.world", + Err: errors.New("bar: is missing"), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Validate(tt.obj) + + if len(tt.wantErrs) == 0 { + assert.Nil(t, err, "validation error should be nil") + } + + got := validate.Errors(err) + assert.ElementsMatch(t, tt.wantErrs, got) + }) + } +} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000..d171925 --- /dev/null +++ b/validator.go @@ -0,0 +1,161 @@ +package validate + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + + "go.uber.org/multierr" +) + +// FieldNameFunc is a function which converts a given reflect.StructField to a +// string. The default will lookup json, yaml, and form field tags. +type FieldNameFunc func(reflect.StructField) string + +// FieldJoinFunc joins a path slice with a given field. Both path and field may +// be empty values. +type FieldJoinFunc func(path []string, field string) string + +// Validator validates Validatable objects. +type Validator struct { + fieldName FieldNameFunc + fieldJoin FieldJoinFunc +} + +// New creates a new Validator. +func New() *Validator { + return &Validator{} +} + +// Validate will validate the given object. Structs, maps, slices, and arrays +// will have each of their fields/items validated, effectively performing a +// deep-validation. +func (s *Validator) Validate(data interface{}) error { + if s.fieldName == nil { + s.fieldName = DefaultFieldName + } + + if s.fieldJoin == nil { + s.fieldJoin = DefaultFieldJoin + } + + return s.validate(nil, data) +} + +// FieldNameFunc allows setting a custom FieldNameFunc method. It receives a +// reflect.StructField, and must return a string for the name of that field. If +// the returned string is empty, validation will not run against the field's +// value, or any nested data within. +func (s *Validator) FieldNameFunc(f FieldNameFunc) { + s.fieldName = f +} + +// FieldJoinFunc allows setting a custom FieldJoinFunc method. It receives a +// string slice of parent fields, and a string of the field name the error is +// reported against. All parent paths, must be joined with the current. +func (s *Validator) FieldJoinFunc(f FieldJoinFunc) { + s.fieldJoin = f +} + +func (s *Validator) validate(path []string, data interface{}) error { + var errs error + if data == nil { + return nil + } + d := reflect.ValueOf(data) + if d.Kind() == reflect.Ptr { + if d.IsNil() { + return nil + } + d = d.Elem() + } + + if v, ok := data.(Validatable); ok { + verrs := v.Validate() + for _, err := range multierr.Errors(verrs) { + // Create a new Error for all errors returned by Validate function + // to correctly resolve field name, and also field path in relation + // to parent objects being validated. + newErr := &Error{} + + e := &Error{} + if ok := errors.As(err, &e); ok { + field := e.Field + if field != "" { + if sf, ok := d.Type().FieldByName(e.Field); ok { + field = s.fieldName(sf) + } + } + newErr.Field = s.fieldJoin(path, field) + newErr.Msg = e.Msg + newErr.Err = e.Err + } else { + newErr.Field = s.fieldJoin(path, "") + newErr.Err = err + } + + errs = multierr.Append(errs, newErr) + } + } + + switch d.Kind() { //nolint:exhaustive + case reflect.Slice, reflect.Array: + for i := 0; i < d.Len(); i++ { + v := d.Index(i) + err := s.validate(append(path, strconv.Itoa(i)), v.Interface()) + errs = multierr.Append(errs, err) + } + case reflect.Map: + for _, k := range d.MapKeys() { + v := d.MapIndex(k) + err := s.validate(append(path, fmt.Sprintf("%v", k)), v.Interface()) + errs = multierr.Append(errs, err) + } + case reflect.Struct: + for i := 0; i < d.NumField(); i++ { + v := d.Field(i) + fldName := s.fieldName(d.Type().Field(i)) + if v.CanSet() && fldName != "" { + err := s.validate(append(path, fldName), v.Interface()) + errs = multierr.Append(errs, err) + } + } + } + return errs +} + +// DefaultFieldName is the default FieldNameFunc used by Validator. +// +// Uses json, yaml, and form field tags to lookup field name first. +func DefaultFieldName(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + + if name == "" { + name = strings.SplitN(fld.Tag.Get("yaml"), ",", 2)[0] + } + + if name == "" { + name = strings.SplitN(fld.Tag.Get("form"), ",", 2)[0] + } + + if name == "-" { + return "" + } + + if name == "" { + return fld.Name + } + + return name +} + +// DefaultFieldJoin is the default FieldJoinFunc used by Validator. +func DefaultFieldJoin(path []string, field string) string { + if field != "" { + path = append(path, field) + } + + return strings.Join(path, ".") +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..bf47c3a --- /dev/null +++ b/validator_test.go @@ -0,0 +1,86 @@ +package validate + +import ( + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// +// Test helper types +// + +type testStruct struct { + Foo string `json:"foo"` + + f func() error +} + +func (s *testStruct) Validate() error { + if s.f == nil { + return nil + } + + return s.f() +} + +type testNestedStruct struct { + OtherField *testStruct `yaml:"other_field"` +} + +type MyStruct struct { + Name string + Kind string +} + +// +// Tests +// + +func TestNew(t *testing.T) { + got := New() + + assert.IsType(t, &Validator{}, got) +} + +func TestValidator_FieldNameFunc(t *testing.T) { + v := New() + v.FieldNameFunc(func(sf reflect.StructField) string { + return "<" + strings.ToUpper(sf.Name) + ">" + }) + err := v.Validate(&testNestedStruct{ + OtherField: &testStruct{f: func() error { + return &Error{Field: "Foo", Msg: "oops"} + }}, + }) + + got := Errors(err) + + assert.ElementsMatch(t, []error{ + &Error{Field: "