Merge pull request #1 from romdo/initial-implementation

feat(validate): initial implementation
This commit is contained in:
2021-08-22 21:55:38 +01:00
committed by GitHub
17 changed files with 2964 additions and 2 deletions

145
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/*.tidy-check
/bin/*
/coverage.out
/output.txt

99
.golangci.yml Normal file
View File

@@ -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

192
Makefile Normal file
View File

@@ -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))

View File

@@ -4,10 +4,75 @@
<p align="center"> <p align="center">
<strong> <strong>
[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.
</strong> </strong>
</p> </p>
## 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) [MIT](https://github.com/romdo/go-conventionalcommit/blob/main/LICENSE)

67
error.go Normal file
View File

@@ -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)
}

456
error_test.go Normal file
View File

@@ -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)
})
}
}

38
examples/basic/basic.go Normal file
View File

@@ -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())
}
}

110
examples/complex/complex.go Normal file
View File

@@ -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)
}
}

9
go.mod Normal file
View File

@@ -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
)

19
go.sum Normal file
View File

@@ -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=

36
helpers.go Normal file
View File

@@ -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
}

406
helpers_test.go Normal file
View File

@@ -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)
})
}
}

162
validate.go Normal file
View File

@@ -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
}

907
validate_test.go Normal file
View File

@@ -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)
})
}
}

161
validator.go Normal file
View File

@@ -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, ".")
}

86
validator_test.go Normal file
View File

@@ -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: "<OTHERFIELD>.<FOO>", Msg: "oops"},
}, got)
}
func TestValidator_FieldJoinFunc(t *testing.T) {
v := New()
v.FieldJoinFunc(func(path []string, field string) string {
if field != "" {
path = append(path, field)
}
return "[" + strings.Join(path, "][") + "]"
})
err := v.Validate(&testNestedStruct{
OtherField: &testStruct{f: func() error {
return &Error{Field: "Foo", Msg: "oops"}
}},
})
got := Errors(err)
assert.ElementsMatch(t, []error{
&Error{Field: "[other_field][foo]", Msg: "oops"},
}, got)
}