From 66be78f9bc8ee30672b7564c241f01a773e9f727 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 26 May 2026 02:51:05 +0300 Subject: [PATCH 1/2] add custom validator --- .gitignore | 29 ++- Makefile | 27 +-- README.md | 270 +++++++++++++++++++++++- cmd/govld/main.go | 249 ++++++++++++++++++++++ common.go | 49 +++++ convert.go | 137 ++++++++++++ convert_test.go | 25 +++ domain.go => domain/domain.go | 18 +- domain_test.go => domain/domain_test.go | 24 +-- example/internal/adapt_handlers_gen.go | 75 +++++++ example/internal/example.go | 36 ++++ example/internal/example_test.go | 89 ++++++++ go.mod | 9 +- go.sum | 8 + handle_adapt.go | 170 +++++++++++++++ handle_adapt_test.go | 63 ++++++ internal/cache/cache.go | 29 +++ internal/pool/pool.go | 36 ++++ internal/util/util.go | 26 +++ store.go | 47 +++++ validate_callback.go | 63 ++++++ validate_callback_test.go | 146 +++++++++++++ validate_struct.go | 233 ++++++++++++++++++++ validate_struct_test.go | 173 +++++++++++++++ validator.go | 32 +++ validator_test.go | 148 +++++++++++++ ver.go => version/ver.go | 16 +- ver_test.go => version/ver_test.go | 12 +- 28 files changed, 2174 insertions(+), 65 deletions(-) mode change 100644 => 100755 .gitignore mode change 100644 => 100755 Makefile create mode 100644 cmd/govld/main.go create mode 100644 common.go create mode 100644 convert.go create mode 100644 convert_test.go rename domain.go => domain/domain.go (84%) rename domain_test.go => domain/domain_test.go (83%) create mode 100644 example/internal/adapt_handlers_gen.go create mode 100644 example/internal/example.go create mode 100644 example/internal/example_test.go create mode 100644 go.sum create mode 100644 handle_adapt.go create mode 100644 handle_adapt_test.go create mode 100644 internal/cache/cache.go create mode 100644 internal/pool/pool.go create mode 100644 internal/util/util.go create mode 100644 store.go create mode 100644 validate_callback.go create mode 100644 validate_callback_test.go create mode 100644 validate_struct.go create mode 100644 validate_struct_test.go create mode 100644 validator.go create mode 100644 validator_test.go rename ver.go => version/ver.go (84%) rename ver_test.go => version/ver_test.go (80%) diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index ae81252..2d09f4d --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,20 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins + +.tools/ +bin/ +vendor/ +build/ +.idea/ +.vscode/ +coverage.txt +coverage.out *.exe *.exe~ *.dll *.so *.dylib - -# Test binary, built with `go test -c` +*.db +*.db-journal +*.mmdb *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum -.tools/ +.env \ No newline at end of file diff --git a/Makefile b/Makefile old mode 100644 new mode 100755 index 918d40d..92b980e --- a/Makefile +++ b/Makefile @@ -1,31 +1,34 @@ +SHELL=/bin/bash .PHONY: install install: - go install github.com/osspkg/devtool@latest - -.PHONY: setup -setup: - devtool setup-lib + go install go.osspkg.com/goppy/v2/cmd/goppy@latest + goppy setup-lib .PHONY: lint lint: - devtool lint + goppy lint .PHONY: license license: - devtool license + goppy license .PHONY: build build: - devtool build --arch=amd64 + goppy build --arch=amd64 .PHONY: tests tests: - devtool test + goppy test -.PHONY: pre-commite -pre-commite: setup lint build tests +.PHONY: pre-commit +pre-commit: install license lint tests build .PHONY: ci -ci: install setup lint build tests +ci: pre-commit + +govld_install: + go install ./cmd/govld +govld_generate: govld_install + govld -pkg=./example/internal \ No newline at end of file diff --git a/README.md b/README.md index c8ea047..d58f00a 100644 --- a/README.md +++ b/README.md @@ -1 +1,269 @@ -# go-validate \ No newline at end of file +# go.osspkg.com/validate + +[![Go Reference](https://pkg.go.dev/badge/go.osspkg.com/validate.svg)](https://pkg.go.dev/go.osspkg.com/validate) +[![Go Report Card](https://goreportcard.com/badge/go.osspkg.com/validate)](https://goreportcard.com/report/go.osspkg.com/validate) +[![License](https://img.shields.io/badge/license-BSD--3-blue.svg)](LICENSE) + +**validate** is a lightweight, extensible validation library for Go with zero reflection overhead for callbacks, struct tagging support, and optional code generation for type-safe adapters. + +## Features + +- **Rule‑based validation** – register named rules with custom handlers. +- **Struct validation** – use `validate` struct tags with support for `required` and multiple rules. +- **Callback‑based validation** – validate multiple values in a single pass with `Optional`/`Require`. +- **Type‑safe adapters** – generate boilerplate‑free adapters from your own functions using `govld`. +- **String decoding** – automatically convert string inputs to most built‑in types and common interfaces. +- **Zero‑allocation pools** – internal pooling for callback validators to reduce GC pressure. +- **Generics** – used internally for caches and pools (Go 1.18+). + +## Installation + +```bash +go get go.osspkg.com/validate +``` + +To use the code generation tool: + +```bash +go install go.osspkg.com/validate/cmd/govld@latest +``` + +## Quick Start + +### 1. Register a rule and validate a struct + +```go +package main + +import ( + "context" + "fmt" + "go.osspkg.com/validate" +) + +func main() { + v := validate.New() + + // Register a rule that checks if an int64 is greater than a reference + _ = v.Register(validate.Rule{ + Name: "gt", + Handle: validate.HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + val, ok := value.(int64) + if !ok { + return fmt.Errorf("expected int64, got %T", value) + } + if len(opts) != 1 { + return fmt.Errorf("expected 1 option") + } + ref, ok := opts[0].(int64) + if !ok { + return fmt.Errorf("option must be int64") + } + if val <= ref { + return fmt.Errorf("value %d must be greater than %d", val, ref) + } + return nil + }), + }) + + type User struct { + Age int64 `validate:"required;gt=18"` + } + + u := &User{Age: 25} + if err := v.ValidateStruct(context.Background(), u); err != nil { + fmt.Println("Validation failed:", err) + } else { + fmt.Println("User is valid") + } +} +``` + +### 2. Callback‑based validation + +```go +func validateUser(ctx context.Context, v *validate.Validator, userID int64, name string) error { + return v.Validate(ctx, func(c validate.Callback) { + c.Require("gt", userID, int64(0)) // userID > 0 + c.Optional("nonempty", name) // only validated if name != "" + }) +} +``` + +## Core Concepts + +### Rule + +A rule consists of a **name** (unique identifier) and a **handler** that implements `validate.Handle`: + +```go +type Handle interface { + ValidateHandle(ctx context.Context, value any, opts ...any) error +} +``` + +The `validate.HandlerFunc` type allows you to turn any function with the matching signature into a handler. + +### Struct Tags + +Use the tag key `validate`. Multiple rules are separated by `;`. The special `required` tag makes the field mandatory (zero values are not skipped). + +Examples: + +```go +type Example struct { + ID int `validate:"required;gt=0"` + Name string `validate:"nonempty;max=64"` + Score float64 `validate:"min=-10;max=100"` +} +``` + +Rules can accept comma‑separated options: + +```go +`validate:"in=admin,moderator,user"` +``` + +### Callback API + +- `Require(name, value, opts...)` – always runs the validation. Fails if the rule returns an error. +- `Optional(name, value, opts...)` – only runs the validation when the value is **not** its zero value (see `util.IsDefaultValue`). Useful for partial updates. + +## Code Generation (`govld`) + +Writing handlers manually with `any` type assertions is verbose. The `govld` tool generates type‑safe adapters from your own functions. + +### Step 1: Write a validation function + +```go +//go:generate govld -pkg . + +//govld:gen +func ValidateUID(ctx context.Context, value int64, min int64) error { + if value < min { + return fmt.Errorf("uid %d is less than minimum %d", value, min) + } + return nil +} +``` + +The function must: +- Have at least two parameters: `context.Context` and the value to validate. +- Return only an `error`. +- Be marked with the comment `//govld:gen` (exactly, no spaces). + +### Step 2: Run the generator + +```bash +go generate ./... +``` + +This creates `adapt_handlers_gen.go` containing `ValidateUIDAdaptHandler` – a function that matches `validate.HandlerFunc`. + +### Step 3: Use the generated adapter + +```go +v.Register(validate.Rule{ + Name: "uid", + Handle: validate.HandlerFunc(ValidateUIDAdaptHandler), +}) +``` + +Now you can call the rule with proper types: + +```go +v.Validate(ctx, func(c validate.Callback) { + c.Require("uid", int64(123), int64(100)) +}) +``` + +The generated adapter automatically converts `any` values and string‑encoded options using `validate.StringDecode`. + +## String Decoding + +The `StringDecode` function (used internally by adapters) converts a string into many Go types: + +- Basic types: `string`, `[]byte`, `int`, `uint`, `float`, `complex`, `bool` +- `time.Duration`, `time.Time` (RFC3339) +- Interfaces: `io.Writer`, `encoding.TextUnmarshaler`, `json.Unmarshaler`, `xml.Unmarshaler` +- Structs, maps, slices, arrays – via `json.Unmarshal` + +You can use it directly: + +```go +var port int +if err := validate.StringDecode(&port, "8080"); err != nil { + // handle error +} +``` + +## Benchmarks + +Typical performance on a modern machine (Intel i9-12900KF): + +| Operation | ns/op | allocs/op | B/op | +|--------------------------------|-----------|-----------|------| +| `Validate` (callback) | ~166 | 3 | 48 | +| `ValidateStruct` (simple tags) | ~118 | 2 | 16 | +| `ValidateStruct` with adapters | ~188 | 17 | 376 | + +## Full Example + +```go +package main + +import ( + "context" + "fmt" + "go.osspkg.com/validate" +) + +//go:generate govld -pkg . + +//govld:gen +func positiveInt(ctx context.Context, value int, _ any) error { + if value <= 0 { + return fmt.Errorf("value must be positive") + } + return nil +} + +//govld:gen +func rangeCheck(ctx context.Context, value int, min, max int) error { + if value < min || value > max { + return fmt.Errorf("value %d out of range [%d,%d]", value, min, max) + } + return nil +} + +type Config struct { + Port int `validate:"required;positiveInt"` + Timeout int `validate:"rangeCheck=100,5000"` +} + +func main() { + v := validate.New() + _ = v.Register( + validate.Rule{Name: "positiveInt", Handle: validate.HandlerFunc(positiveIntAdaptHandler)}, + validate.Rule{Name: "rangeCheck", Handle: validate.HandlerFunc(rangeCheckAdaptHandler)}, + ) + + cfg := &Config{Port: 8080, Timeout: 2000} + if err := v.ValidateStruct(context.Background(), cfg); err != nil { + fmt.Println("Invalid config:", err) + } else { + fmt.Println("Config OK") + } +} +``` + +Run with: + +```bash +go generate +go run . +``` + +## License + +BSD 3-Clause – see [LICENSE](LICENSE) file. \ No newline at end of file diff --git a/cmd/govld/main.go b/cmd/govld/main.go new file mode 100644 index 0000000..92539f9 --- /dev/null +++ b/cmd/govld/main.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "go/format" + "go/types" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/go/packages" +) + +const ( + defaultFileName = "adapt_handlers_gen.go" + generateTag = "//govld:gen" +) + +func main() { + pkgPath := flag.String("pkg", ".", "path to pkg (example ./...)") + flag.Parse() + + cfg := &packages.Config{ + Mode: packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedName | packages.NeedFiles, + } + + pkgs, err := packages.Load(cfg, *pkgPath) + if err != nil { + log.Fatalf("load packages: %v", err) + } + if len(pkgs) == 0 { + log.Fatal("packages not found") + } + + for _, pkg := range pkgs { + if len(pkg.Errors) > 0 { + log.Fatalf("packege errors: %v", pkg.Errors) + } + + var funcs []*ast.FuncDecl + for _, file := range pkg.Syntax { + for _, decl := range file.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Doc == nil { + continue + } + for _, comment := range fd.Doc.List { + if strings.TrimSpace(comment.Text) == generateTag { + funcs = append(funcs, fd) + break + } + } + } + } + + if len(funcs) == 0 { + continue + } + + info := pkg.TypesInfo + gen := generator{ + pkg: pkg, + funcs: funcs, + info: info, + imports: make(map[string]string), // path -> alias + usedPkgs: make(map[string]bool), + qualifier: nil, + } + gen.qualifier = gen.makeQualifier() + + { + gen.usedPkgs["context"] = true + gen.usedPkgs["fmt"] = true + + gen.usedPkgs["go.osspkg.com/validate"] = true + gen.imports["go.osspkg.com/validate"] = "validate" + } + + outFile := filepath.Join(pkg.Dir, defaultFileName) + + if err = gen.generate(outFile); err != nil { + log.Fatalf("generate `%s`: %v", outFile, err) + } + + log.Println("done", outFile) + } + +} + +type generator struct { + pkg *packages.Package + funcs []*ast.FuncDecl + info *types.Info + imports map[string]string // collected imports (path -> local name, if needed) + usedPkgs map[string]bool // which packages are actually used in the generated code + qualifier types.Qualifier +} + +func (g *generator) makeQualifier() types.Qualifier { + self := g.pkg.Types + return func(other *types.Package) string { + if other == self { + return "" + } + g.usedPkgs[other.Path()] = true + return other.Name() + } +} + +func (g *generator) generate(filename string) error { + var buf bytes.Buffer + buf.WriteString("// Code generated by govld-gen; DO NOT EDIT.\n\n") + buf.WriteString(fmt.Sprintf("package %s\n\n", g.pkg.Name)) + + var funcBodies bytes.Buffer + for _, fd := range g.funcs { + fnObj := g.info.Defs[fd.Name].(*types.Func) + sig := fnObj.Type().(*types.Signature) + + params := sig.Params() + if params.Len() < 2 { + log.Printf("skip `%s`: not enough parameters", fd.Name) + continue + } + + firstParam := params.At(0) + if !isContextType(firstParam.Type()) { + log.Printf("skip `%s`: first parameter is not context.Context", fd.Name) + continue + } + + valueParam := params.At(1) + optParams := make([]*types.Var, 0) + + for i := 2; i < params.Len(); i++ { + optParams = append(optParams, params.At(i)) + } + + g.emitWrapper(&funcBodies, fd.Name.Name, valueParam, optParams) + } + + var importLines []string + if len(g.usedPkgs) > 0 { + importLines = append(importLines, "import (") + + var paths []string + for p := range g.usedPkgs { + paths = append(paths, p) + } + + sort.Strings(paths) + + for _, p := range paths { + alias := g.imports[p] + if alias == "" { + alias = pkgNameFromPath(p) + } + if alias == g.pkg.Name && p != g.pkg.PkgPath { + alias = alias + "_import" + } + importLines = append(importLines, fmt.Sprintf("\t\"%s\"", p)) + } + + importLines = append(importLines, ")") + } + + buf.WriteString(strings.Join(importLines, "\n")) + buf.WriteString("\n\n") + buf.Write(funcBodies.Bytes()) + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + _ = os.WriteFile(filename+".debug", buf.Bytes(), 0644) + return fmt.Errorf("go format: %w", err) + } + + return os.WriteFile(filename, formatted, 0644) +} + +func (g *generator) emitWrapper(w *bytes.Buffer, name string, value *types.Var, opts []*types.Var) { + q := g.qualifier + valTypeStr := types.TypeString(value.Type(), q) + fmt.Fprintf(w, "// %sAdaptHandler is adapter for %s.\n", name, name) + fmt.Fprintf(w, "func %sAdaptHandler(ctx context.Context, value any, opts ...any) (err error) {\n", name) + + fmt.Fprintf(w, "var typedValue %s\n", valTypeStr) + fmt.Fprintf(w, "switch v := value.(type) {\n") + fmt.Fprintf(w, "case string:\n") + fmt.Fprintf(w, "if err = validate.StringDecode(&typedValue, v); err != nil {") + fmt.Fprintf(w, "return fmt.Errorf(\"convert value to %s: %%w\", err)\n}\n", valTypeStr) + fmt.Fprintf(w, "case %s:\n", valTypeStr) + fmt.Fprintf(w, "typedValue = v\n") + fmt.Fprintf(w, "default:\n") + fmt.Fprintf(w, "return fmt.Errorf(\"%s: expected value of type %s, got %%T\", value)\n", name, valTypeStr) + fmt.Fprintf(w, "}\n") + fmt.Fprintf(w, "\n") + + if len(opts) > 0 { + fmt.Fprintf(w, "if len(opts) != %d {\n", len(opts)) + fmt.Fprintf(w, "return fmt.Errorf(\"%s: expected %d opts, got %%d\", len(opts))\n", name, len(opts)) + fmt.Fprintf(w, "}\n") + fmt.Fprintf(w, "\n") + + for i, param := range opts { + optTypeStr := types.TypeString(param.Type(), q) + + fmt.Fprintf(w, "var opt%d %s\n", i, optTypeStr) + fmt.Fprintf(w, "switch v := opts[%d].(type) {\n", i) + fmt.Fprintf(w, "case string:\n") + fmt.Fprintf(w, "if err = validate.StringDecode(&opt%d, v); err != nil {", i) + fmt.Fprintf(w, "return fmt.Errorf(\"convert opt%d to %s: %%w\", err)\n}\n", i, optTypeStr) + fmt.Fprintf(w, "case %s:\n", optTypeStr) + fmt.Fprintf(w, "opt%d = v\n", i) + fmt.Fprintf(w, "default:\n") + fmt.Fprintf(w, "return fmt.Errorf(\"%s: expected opt[%d] of type %s, got %%T\", opts[%d])\n", name, + i, optTypeStr, i) + fmt.Fprintf(w, "}\n") + fmt.Fprintf(w, "\n") + } + } + + fmt.Fprintf(w, "return %s(ctx, typedValue", name) + for i := range opts { + fmt.Fprintf(w, ", opt%d", i) + } + fmt.Fprintf(w, ")\n") + fmt.Fprintf(w, "}\n\n") +} + +func isContextType(t types.Type) bool { + named, ok := t.(*types.Named) + if !ok { + return false + } + return named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "context" && named.Obj().Name() == "Context" +} + +func pkgNameFromPath(path string) string { + idx := strings.LastIndex(path, "/") + if idx >= 0 { + return path[idx+1:] + } + return path +} diff --git a/common.go b/common.go new file mode 100644 index 0000000..906a2b4 --- /dev/null +++ b/common.go @@ -0,0 +1,49 @@ +package validate + +import ( + "context" + "errors" + "strings" +) + +type ( + Name string + + Handle interface { + ValidateHandle(ctx context.Context, value any, opts ...any) error + } + + resolver interface { + Resolve(name Name) (Rule, bool) + } + + tagLookup interface { + Lookup(key string) (value string, ok bool) + } +) + +type HandlerFunc func(ctx context.Context, value any, opts ...any) error + +func (f HandlerFunc) ValidateHandle(ctx context.Context, value any, opts ...any) error { + return f(ctx, value, opts...) +} + +type Rule struct { + Name Name + Handle Handle +} + +func (r Rule) Validate() error { + if r.Handle == nil { + return errors.New("no handler for rule") + } + if len(strings.TrimSpace(string(r.Name))) == 0 { + return errors.New("no name for rule") + } + return nil +} + +type tagInfo struct { + Name Name + Opts []any +} diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..b4c4888 --- /dev/null +++ b/convert.go @@ -0,0 +1,137 @@ +package validate + +import ( + "encoding" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "reflect" + "strconv" + "time" +) + +func StringDecode(obj any, s string) (err error) { + if len(s) == 0 { + return + } + + ref := reflect.ValueOf(obj) + if ref.Kind() != reflect.Ptr { + return fmt.Errorf("got not a pointer") + } + + if ref.IsNil() { + return fmt.Errorf("got nil pointer") + } + + switch p := obj.(type) { + + case *string: + *p = s + + case *[]byte: + *p = []byte(s) + + case *int: + var val int64 + val, err = strconv.ParseInt(s, 10, strconv.IntSize) + *p = int(val) + + case *int8: + var val int64 + val, err = strconv.ParseInt(s, 10, 8) + *p = int8(val) + + case *int16: + var val int64 + val, err = strconv.ParseInt(s, 10, 16) + *p = int16(val) + + case *int32: + var val int64 + val, err = strconv.ParseInt(s, 10, 32) + *p = int32(val) + + case *int64: + *p, err = strconv.ParseInt(s, 10, 64) + + case *uint: + var val uint64 + val, err = strconv.ParseUint(s, 10, strconv.IntSize) + *p = uint(val) + + case *uint8: + var val uint64 + val, err = strconv.ParseUint(s, 10, 8) + *p = uint8(val) + + case *uint16: + var val uint64 + val, err = strconv.ParseUint(s, 10, 16) + *p = uint16(val) + + case *uint32: + var val uint64 + val, err = strconv.ParseUint(s, 10, 32) + *p = uint32(val) + + case *uint64: + *p, err = strconv.ParseUint(s, 10, 64) + + case *float32: + var val float64 + val, err = strconv.ParseFloat(s, 32) + *p = float32(val) + + case *float64: + *p, err = strconv.ParseFloat(s, 64) + + case *complex64: + var val complex128 + val, err = strconv.ParseComplex(s, 64) + *p = complex64(val) + + case *complex128: + *p, err = strconv.ParseComplex(s, 128) + + case *bool: + *p, err = strconv.ParseBool(s) + + case *time.Duration: + *p, err = time.ParseDuration(s) + + case *time.Time: + *p, err = time.Parse(time.RFC3339, s) + + case io.Writer: + _, err = p.Write([]byte(s)) + + case io.StringWriter: + _, err = p.WriteString(s) + + case encoding.BinaryUnmarshaler: + err = p.UnmarshalBinary([]byte(s)) + + case encoding.TextUnmarshaler: + err = p.UnmarshalText([]byte(s)) + + case json.Unmarshaler: + err = p.UnmarshalJSON([]byte(s)) + + case xml.Unmarshaler: + err = xml.Unmarshal([]byte(s), p) + + default: + + switch ref.Elem().Kind() { + case reflect.Struct, reflect.Map, reflect.Array, reflect.Slice: + err = json.Unmarshal([]byte(s), obj) + + default: + err = fmt.Errorf("unsupported type: %T", obj) + } + } + + return +} diff --git a/convert_test.go b/convert_test.go new file mode 100644 index 0000000..28c0f82 --- /dev/null +++ b/convert_test.go @@ -0,0 +1,25 @@ +package validate + +import ( + "testing" +) + +/* +goos: linux +goarch: amd64 +pkg: go.osspkg.com/validate +cpu: 12th Gen Intel(R) Core(TM) i9-12900KF +Benchmark_ConvertFloat +Benchmark_ConvertFloat-24 260549199 4.410 ns/op 8 B/op 1 allocs/op +*/ +func Benchmark_ConvertFloat(b *testing.B) { + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + var f float64 + if err := StringDecode(&f, "3.141592653589793"); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/domain.go b/domain/domain.go similarity index 84% rename from domain.go rename to domain/domain.go index 739df4e..8f27a9b 100644 --- a/domain.go +++ b/domain/domain.go @@ -3,7 +3,7 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ -package validate +package domain import ( "fmt" @@ -13,12 +13,12 @@ import ( var dot = byte('.') -func GetDomainLevel(s string, level int) string { +func Level(s string, level int) string { if level == 0 { return "." } var err error - s, err = NormalizeDomain(s) + s, err = Normalize(s) if err != nil { return "." } @@ -40,15 +40,15 @@ func GetDomainLevel(s string, level int) string { return s[pos:] } -func CountDomainLevels(s string) int { - ss, err := NormalizeDomain(s) +func CountLevels(s string) int { + ss, err := Normalize(s) if err != nil { return 0 } return strings.Count(ss, ".") } -func IsValidDomain(s string) bool { +func IsValid(s string) bool { d, b := 0, []byte(s) for i := 0; i < len(b); i++ { if b[i] == '.' { @@ -71,15 +71,15 @@ func IsValidDomain(s string) bool { return true } -func NormalizeDomain(s string) (string, error) { - b, err := NormalizeDomainBytes([]byte(s)) +func Normalize(s string) (string, error) { + b, err := NormalizeBytes([]byte(s)) if err != nil { return "", err } return string(b), nil } -func NormalizeDomainBytes(b []byte) ([]byte, error) { +func NormalizeBytes(b []byte) ([]byte, error) { if len(b) < 1 { return nil, fmt.Errorf("invalid domain") } diff --git a/domain_test.go b/domain/domain_test.go similarity index 83% rename from domain_test.go rename to domain/domain_test.go index 2c0d1c1..f1f1154 100644 --- a/domain_test.go +++ b/domain/domain_test.go @@ -3,7 +3,7 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ -package validate +package domain import ( "fmt" @@ -78,8 +78,8 @@ func TestUnit_GetDomainLevel(t *testing.T) { } for i, tt := range tests { t.Run(fmt.Sprintf("Case %d", i), func(t *testing.T) { - if got := GetDomainLevel(tt.args.s, tt.args.level); got != tt.want { - t.Errorf("GetDomainLevel() = %v, want %v", got, tt.want) + if got := Level(tt.args.s, tt.args.level); got != tt.want { + t.Errorf("Level() = %v, want %v", got, tt.want) } }) } @@ -91,7 +91,7 @@ func Benchmark_GetDomainLevel(b *testing.B) { b.RunParallel(func(p *testing.PB) { for p.Next() { - GetDomainLevel("www.domain.ltd.", 2) + Level("www.domain.ltd.", 2) } }) } @@ -102,7 +102,7 @@ func Benchmark_IsValidDomain(b *testing.B) { b.RunParallel(func(p *testing.PB) { for p.Next() { - IsValidDomain("www.domain.ltd.") + IsValid("www.domain.ltd.") } }) } @@ -111,7 +111,7 @@ func Benchmark_NormalizeDomainBytes(b *testing.B) { b.ReportAllocs() b.RunParallel(func(p *testing.PB) { for p.Next() { - NormalizeDomainBytes([]byte(" WWW.Domain.ltd ")) + NormalizeBytes([]byte(" WWW.Domain.ltd ")) } }) } @@ -120,7 +120,7 @@ func Benchmark_NormalizeDomain(b *testing.B) { b.ReportAllocs() b.RunParallel(func(p *testing.PB) { for p.Next() { - NormalizeDomain(" WWW.Domain.ltd ") + Normalize(" WWW.Domain.ltd ") } }) } @@ -189,13 +189,13 @@ func TestUnit_NormalizeDomain(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NormalizeDomain(tt.domain) + got, err := Normalize(tt.domain) if (err != nil) != tt.wantErr { - t.Errorf("NormalizeDomain() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Normalize() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("NormalizeDomain() got = %v, want %v", got, tt.want) + t.Errorf("Normalize() got = %v, want %v", got, tt.want) } }) } @@ -230,8 +230,8 @@ func TestUnit_CountDomainLevels(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := CountDomainLevels(tt.arg); got != tt.want { - t.Errorf("CountDomainLevels() = %v, want %v", got, tt.want) + if got := CountLevels(tt.arg); got != tt.want { + t.Errorf("CountLevels() = %v, want %v", got, tt.want) } }) } diff --git a/example/internal/adapt_handlers_gen.go b/example/internal/adapt_handlers_gen.go new file mode 100644 index 0000000..3fddf6c --- /dev/null +++ b/example/internal/adapt_handlers_gen.go @@ -0,0 +1,75 @@ +// Code generated by govld-gen; DO NOT EDIT. + +package internal + +import ( + "context" + "fmt" + "go.osspkg.com/validate" +) + +// ValidateUIDAdaptHandler is adapter for ValidateUID. +func ValidateUIDAdaptHandler(ctx context.Context, value any, opts ...any) (err error) { + var typedValue int64 + switch v := value.(type) { + case string: + if err = validate.StringDecode(&typedValue, v); err != nil { + return fmt.Errorf("convert value to int64: %w", err) + } + case int64: + typedValue = v + default: + return fmt.Errorf("ValidateUID: expected value of type int64, got %T", value) + } + + if len(opts) != 1 { + return fmt.Errorf("ValidateUID: expected 1 opts, got %d", len(opts)) + } + + var opt0 int64 + switch v := opts[0].(type) { + case string: + if err = validate.StringDecode(&opt0, v); err != nil { + return fmt.Errorf("convert opt0 to int64: %w", err) + } + case int64: + opt0 = v + default: + return fmt.Errorf("ValidateUID: expected opt[0] of type int64, got %T", opts[0]) + } + + return ValidateUID(ctx, typedValue, opt0) +} + +// ValidateGIDAdaptHandler is adapter for ValidateGID. +func ValidateGIDAdaptHandler(ctx context.Context, value any, opts ...any) (err error) { + var typedValue int64 + switch v := value.(type) { + case string: + if err = validate.StringDecode(&typedValue, v); err != nil { + return fmt.Errorf("convert value to int64: %w", err) + } + case int64: + typedValue = v + default: + return fmt.Errorf("ValidateGID: expected value of type int64, got %T", value) + } + + if len(opts) != 1 { + return fmt.Errorf("ValidateGID: expected 1 opts, got %d", len(opts)) + } + + var opt0 GUID + switch v := opts[0].(type) { + case string: + if err = validate.StringDecode(&opt0, v); err != nil { + return fmt.Errorf("convert opt0 to GUID: %w", err) + } + case GUID: + opt0 = v + default: + return fmt.Errorf("ValidateGID: expected opt[0] of type GUID, got %T", opts[0]) + } + + return ValidateGID(ctx, typedValue, opt0) +} diff --git a/example/internal/example.go b/example/internal/example.go new file mode 100644 index 0000000..0869cd1 --- /dev/null +++ b/example/internal/example.go @@ -0,0 +1,36 @@ +package internal + +import ( + "context" + "fmt" + + "go.osspkg.com/validate" +) + +const ( + RuleNameUID validate.Name = "uid" + RuleNameGID validate.Name = "gid" +) + +//govld:gen +func ValidateUID(_ context.Context, value int64, ref int64) error { + if value <= ref { + return fmt.Errorf("invalid UID: %d", value) + } + return nil +} + +type GUID string + +func (g *GUID) UnmarshalText(b []byte) error { + *g = GUID(b) + return nil +} + +//govld:gen +func ValidateGID(_ context.Context, value int64, ref GUID) error { + if value <= 0 { + return fmt.Errorf("invalid GID: %d", value) + } + return nil +} diff --git a/example/internal/example_test.go b/example/internal/example_test.go new file mode 100644 index 0000000..1d746d8 --- /dev/null +++ b/example/internal/example_test.go @@ -0,0 +1,89 @@ +package internal + +import ( + "context" + "testing" + + "go.osspkg.com/validate" +) + +func TestExample1(t *testing.T) { + vld := validate.New() + + failIfError(t, + vld.Register( + validate.Rule{ + Name: RuleNameUID, + Handle: validate.HandlerFunc(ValidateUIDAdaptHandler), + }, + validate.Rule{ + Name: RuleNameGID, + Handle: validate.HandlerFunc(ValidateGIDAdaptHandler), + }, + ), + "Register()") + + failIfError(t, + vld.Validate(context.TODO(), func(c validate.Callback) { + c.Require(RuleNameUID, "123", int64(10)) + }), + "Validate()", + ) + + type Demo struct { + UserID int64 `json:"user_id" validate:"required;uid=10;gid=100"` + } + + failIfError(t, + vld.ValidateStruct(context.TODO(), &Demo{ + UserID: 123, + }), + "ValidateStruct()", + ) +} + +func failIfError(t testing.TB, err error, message string) { + if err != nil { + t.Fatalf("%s: %v", message, err) + } +} + +func BenchmarkExample1(b *testing.B) { + vld := validate.New() + + type Demo struct { + UserID int64 `json:"user_id" validate:"required;uid=10;gid=100"` + } + + model := &Demo{UserID: 123} + ctx := context.Background() + + failIfError(b, + vld.Register( + validate.Rule{ + Name: RuleNameUID, + Handle: validate.HandlerFunc(ValidateUIDAdaptHandler), + }, + validate.Rule{ + Name: RuleNameGID, + Handle: validate.HandlerFunc(ValidateGIDAdaptHandler), + }, + ), + "Register()") + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + failIfError(b, + vld.Validate(ctx, func(c validate.Callback) { + c.Optional(RuleNameUID, "", int64(1000)) + }), + "Validate()", + ) + + failIfError(b, vld.ValidateStruct(ctx, model), "ValidateStruct()") + } + }) + +} diff --git a/go.mod b/go.mod index 38bbe88..c69e68d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module go.osspkg.com/validate -go 1.21 +go 1.25.0 + +require golang.org/x/tools v0.45.0 + +require ( + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be8b55b --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= diff --git a/handle_adapt.go b/handle_adapt.go new file mode 100644 index 0000000..5ef350a --- /dev/null +++ b/handle_adapt.go @@ -0,0 +1,170 @@ +package validate + +import ( + "context" + "fmt" + "reflect" +) + +var ( + ctxType = reflect.TypeFor[context.Context]() + errType = reflect.TypeFor[error]() +) + +const requireArgs = 2 + +func adaptError(err error) HandlerFunc { + return func(ctx context.Context, value any, opts ...any) error { + return err + } +} + +func AdaptHandlerFunc(fn any) HandlerFunc { + fnVal := reflect.ValueOf(fn) + fnType := fnVal.Type() + + if fnType.Kind() != reflect.Func { + return adaptError(fmt.Errorf("AdaptHandlerFunc: expects a function")) + } + + numIn := fnType.NumIn() + if numIn < requireArgs { + return adaptError(fmt.Errorf("AdaptHandlerFunc: function must have at least 2 arguments (ctx, value)")) + } + + if !fnType.In(0).Implements(ctxType) { + return adaptError(fmt.Errorf("AdaptHandlerFunc: first argument must implement context.Context")) + } + + if fnType.NumOut() != 1 || !fnType.Out(0).Implements(errType) { + return adaptError(fmt.Errorf("AdaptHandlerFunc: function must return exactly one value of type error")) + } + + targetValType := fnType.In(1) + + targetOptTypes := make([]reflect.Type, 0, numIn-2) + for i := 0; i < numIn-2; i++ { + targetOptTypes = append(targetOptTypes, fnType.In(i+2)) + } + + return func(ctx context.Context, value any, opts ...any) error { + if len(opts) < numIn-2 { + return fmt.Errorf("AdaptHandlerFunc: missing arguments, expected %d options, got %d", numIn-2, len(opts)) + } + + args := make([]reflect.Value, numIn) + args[0] = reflect.ValueOf(ctx) + + if value == nil { + args[1] = reflect.Zero(targetValType) + } else { + rVal, err := castReflect(value, targetValType) + if err != nil { + return fmt.Errorf("AdaptHandlerFunc: value type mismatch error: %w", err) + } + if rValRef, ok := rVal.(reflect.Value); ok { + args[1] = rValRef + } else { + args[1] = reflect.ValueOf(rVal) + } + } + + for i, targetOptType := range targetOptTypes { + rVal, err := castReflect(opts[i], targetOptType) + if err != nil { + return fmt.Errorf("AdaptHandlerFunc: option %d error: %w", i, err) + } + if rValRef, ok := rVal.(reflect.Value); ok { + args[i+2] = rValRef + } else { + args[i+2] = reflect.ValueOf(rVal) + } + } + + res := fnVal.Call(args) + if !res[0].IsNil() { + return res[0].Interface().(error) + } + + return nil + } +} + +func castReflect(val any, target reflect.Type) (any, error) { + var err error + if s, ok := val.(string); ok { + switch target.Kind() { + case reflect.String: + return s, nil + + case reflect.Int: + var v int + err = StringDecode(&v, s) + return v, err + case reflect.Int8: + var v int8 + err = StringDecode(&v, s) + return v, err + case reflect.Int16: + var v int16 + err = StringDecode(&v, s) + return v, err + case reflect.Int32: + var v int32 + err = StringDecode(&v, s) + return v, err + case reflect.Int64: + var v int64 + err = StringDecode(&v, s) + return v, err + + case reflect.Uint: + var v uint + err = StringDecode(&v, s) + return v, err + case reflect.Uint8: + var v uint8 + err = StringDecode(&v, s) + return v, err + case reflect.Uint16: + var v uint16 + err = StringDecode(&v, s) + return v, err + case reflect.Uint32: + var v uint32 + err = StringDecode(&v, s) + return v, err + case reflect.Uint64: + var v uint64 + err = StringDecode(&v, s) + return v, err + + case reflect.Float32: + var v float32 + err = StringDecode(&v, s) + return v, err + case reflect.Float64: + var v float64 + err = StringDecode(&v, s) + return v, err + + case reflect.Bool: + var v bool + err = StringDecode(&v, s) + return v, err + + default: + } + } + + v := reflect.ValueOf(val) + if v.Type() == target { + return v, nil + } + + if v.Type().ConvertibleTo(target) { + return v.Convert(target), nil + } + + return reflect.Value{}, fmt.Errorf("cannot convert %T to %v", val, target) +} diff --git a/handle_adapt_test.go b/handle_adapt_test.go new file mode 100644 index 0000000..5290ed7 --- /dev/null +++ b/handle_adapt_test.go @@ -0,0 +1,63 @@ +package validate + +import ( + "context" + "fmt" + "testing" +) + +/* +goos: linux +goarch: amd64 +pkg: go.osspkg.com/validate +cpu: 12th Gen Intel(R) Core(TM) i9-12900KF +Benchmark_ValidateStruct_WithAdapt +Benchmark_ValidateStruct_WithAdapt-24 6333072 188.0 ns/op 376 B/op 17 allocs/op +*/ +func Benchmark_ValidateStruct_WithAdapt(b *testing.B) { + v := New() + v.Register( + Rule{ + Name: "eq", + Handle: AdaptHandlerFunc(func(ctx context.Context, value int64, reference int64) error { + if value != reference { + return fmt.Errorf("expected %d, got %d", reference, value) + } + return nil + }), + }, + Rule{ + Name: "eq2", + Handle: AdaptHandlerFunc(func(ctx context.Context, value, reference float64) error { + if value != reference { + return fmt.Errorf("expected %v, got %v", reference, value) + } + return nil + }), + }, + ) + + type mock struct { + Id1 int `validate:"required;eq=1"` + Id2 int `validate:"required;eq2=2"` + Id3 int `validate:"eq=2"` + } + + mod := &mock{ + Id1: 1, + Id2: 2, + Id3: 0, + } + + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := v.ValidateStruct(ctx, mod); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..549ae81 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,29 @@ +package cache + +import "sync" + +type Cache[K comparable, T any] struct { + data map[K]T + mux sync.RWMutex +} + +func New[K comparable, T any]() *Cache[K, T] { + return &Cache[K, T]{ + data: make(map[K]T, 10), + } +} + +func (c *Cache[K, T]) Get(key K) (T, bool) { + c.mux.RLock() + defer c.mux.RUnlock() + + val, ok := c.data[key] + return val, ok +} + +func (c *Cache[K, T]) Set(key K, val T) { + c.mux.Lock() + defer c.mux.Unlock() + + c.data[key] = val +} diff --git a/internal/pool/pool.go b/internal/pool/pool.go new file mode 100644 index 0000000..6be02d0 --- /dev/null +++ b/internal/pool/pool.go @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024-2025 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package pool + +import "sync" + +type TPool interface { + Reset() +} + +type Pool[T TPool] struct { + callNew func() T + pool sync.Pool +} + +func New[T TPool](callNew func() T) *Pool[T] { + return &Pool[T]{ + pool: sync.Pool{New: func() any { return callNew() }}, + } +} + +func (v *Pool[T]) Get() T { + buf, ok := v.pool.Get().(T) + if !ok { + buf = v.callNew() + } + return buf +} + +func (v *Pool[T]) Put(t T) { + t.Reset() + v.pool.Put(t) +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..8b1afe9 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,26 @@ +package util + +import "reflect" + +func IsDefaultValue(arg any) bool { + switch v := arg.(type) { + case nil: + return true + case string: + return v == "" + case bool: + return !v + case int, int8, int16, int32, int64: + return v == 0 + case uint, uint8, uint16, uint32, uint64: + return v == 0 + case float32, float64: + return v == 0 + default: + rv := reflect.ValueOf(arg) + if !rv.IsValid() { + return true + } + return rv.IsZero() + } +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..df1a24e --- /dev/null +++ b/store.go @@ -0,0 +1,47 @@ +package validate + +import ( + "fmt" + "sync" +) + +type store struct { + list map[Name]Rule + mux sync.RWMutex +} + +func newStore() *store { + return &store{ + list: make(map[Name]Rule, 10), + } +} + +func (s *store) Append(rules ...Rule) error { + s.mux.Lock() + defer s.mux.Unlock() + + for _, rule := range rules { + if err := rule.Validate(); err != nil { + return err + } + + if _, ok := s.list[rule.Name]; ok { + return fmt.Errorf("duplicate rule: %s", rule.Name) + } + + s.list[rule.Name] = rule + } + + return nil +} + +func (s *store) Resolve(name Name) (Rule, bool) { + s.mux.RLock() + defer s.mux.RUnlock() + + rule, ok := s.list[name] + if !ok { + return Rule{}, false + } + return rule, true +} diff --git a/validate_callback.go b/validate_callback.go new file mode 100644 index 0000000..8c0900f --- /dev/null +++ b/validate_callback.go @@ -0,0 +1,63 @@ +package validate + +import ( + "context" + "fmt" + + "go.osspkg.com/validate/internal/pool" + "go.osspkg.com/validate/internal/util" +) + +type Callback interface { + Optional(name Name, value any, opts ...any) + Require(name Name, value any, opts ...any) +} + +var poolCallbackValidator = pool.New[*callbackValidator](func() *callbackValidator { + return &callbackValidator{params: make([]cvParam, 0, 32)} +}) + +type cvParam struct { + require bool + name Name + value any + opts []any +} + +type callbackValidator struct { + params []cvParam +} + +func (v *callbackValidator) Reset() { + v.params = v.params[:0] +} + +func (v *callbackValidator) Optional(name Name, value any, opts ...any) { + v.params = append(v.params, cvParam{require: false, name: name, value: value, opts: opts}) +} + +func (v *callbackValidator) Require(name Name, value any, opts ...any) { + v.params = append(v.params, cvParam{require: true, name: name, value: value, opts: opts}) +} + +func (v *callbackValidator) handler(ctx context.Context, r resolver, p cvParam) error { + rule, ok := r.Resolve(p.name) + if !ok { + return fmt.Errorf("validator `%s` not found", p.name) + } + + if !p.require && util.IsDefaultValue(p.value) { + return nil + } + + return rule.Handle.ValidateHandle(ctx, p.value, p.opts...) +} + +func (v *callbackValidator) run(ctx context.Context, r resolver) error { + for i := range v.params { + if err := v.handler(ctx, r, v.params[i]); err != nil { + return err + } + } + return nil +} diff --git a/validate_callback_test.go b/validate_callback_test.go new file mode 100644 index 0000000..402bf12 --- /dev/null +++ b/validate_callback_test.go @@ -0,0 +1,146 @@ +package validate + +import ( + "context" + "errors" + "fmt" + "testing" +) + +func TestValidator_Validate(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + rules []Rule + callback func(c Callback) + wantErr bool + errString string + }{ + { + name: "require success", + rules: []Rule{ + {Name: "positive", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + if v, ok := value.(int); ok && v > 0 { + return nil + } + return errors.New("must be positive") + })}, + }, + callback: func(c Callback) { + c.Require("positive", 42) + }, + wantErr: false, + }, + { + name: "require failure", + rules: []Rule{ + {Name: "positive", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + return errors.New("value not positive") + })}, + }, + callback: func(c Callback) { + c.Require("positive", -1) + }, + wantErr: true, + }, + { + name: "optional zero value skipped", + rules: []Rule{ + {Name: "positive", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + return errors.New("should not be called") + })}, + }, + callback: func(c Callback) { + c.Optional("positive", 0) + }, + wantErr: false, + }, + { + name: "optional non-zero called", + rules: []Rule{ + {Name: "positive", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + if v, ok := value.(int); ok && v > 0 { + return nil + } + return errors.New("must be positive") + })}, + }, + callback: func(c Callback) { + c.Optional("positive", 5) + }, + wantErr: false, + }, + { + name: "rule not found", + rules: []Rule{ + {Name: "exists", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil })}, + }, + callback: func(c Callback) { + c.Require("unknown", "val") + }, + wantErr: true, + errString: "validator `unknown` not found", + }, + { + name: "multiple calls, first fails", + rules: []Rule{ + {Name: "alpha", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + return errors.New("alpha error") + })}, + {Name: "beta", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil })}, + }, + callback: func(c Callback) { + c.Require("alpha", 1) + c.Optional("beta", "ignored") + }, + wantErr: true, + }, + { + name: "callback with opts", + rules: []Rule{ + {Name: "inRange", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + if len(opts) != 2 { + return fmt.Errorf("expected 2 options, got %d", len(opts)) + } + intVal, ok := value.(int) + if !ok { + return fmt.Errorf("expected int, got %T", value) + } + minVal, ok := opts[0].(int) + if !ok { + return fmt.Errorf("expected int, got %T", value) + } + maxVal, ok := opts[1].(int) + if !ok { + return fmt.Errorf("expected int, got %T", value) + } + if intVal < minVal || intVal > maxVal { + return fmt.Errorf("not in range") + } + return nil + })}, + }, + callback: func(c Callback) { + c.Require("inRange", 18, 1, 19) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := New() + if err := v.Register(tt.rules...); err != nil { + t.Fatalf("Register() error = %v", err) + } + err := v.Validate(ctx, tt.callback) + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && tt.errString != "" && err.Error() != tt.errString { + t.Errorf("expected error %q, got %q", tt.errString, err.Error()) + } + }) + } +} diff --git a/validate_struct.go b/validate_struct.go new file mode 100644 index 0000000..91174fa --- /dev/null +++ b/validate_struct.go @@ -0,0 +1,233 @@ +package validate + +import ( + "context" + "fmt" + "reflect" + "strings" + "sync" + + "go.osspkg.com/validate/internal/cache" +) + +const tagName = "validate" + +type structFieldInfo struct { + Index int + Name string + ParentIndex int + HasParent bool + Required bool + Tags []tagInfo +} + +type structValidator struct { + cache *cache.Cache[reflect.Type, []structFieldInfo] + mux sync.Mutex +} + +func newStructValidator() *structValidator { + return &structValidator{ + cache: cache.New[reflect.Type, []structFieldInfo](), + } +} + +func (v *structValidator) run(ctx context.Context, r resolver, arg any) error { + ref, err := v.refStruct(arg) + if err != nil { + return err + } + + fields, err := v.getStructInfo(ref.Type()) + if err != nil { + return err + } + + for i := 0; i < len(fields); i++ { + field := &fields[i] + + var refField reflect.Value + + if field.HasParent { + refField = ref.Field(field.ParentIndex) + if refField.Kind() == reflect.Ptr { + if refField.IsNil() { + if field.Required { + return fmt.Errorf("got nullible reference to required field `%s`", field.Name) + } + continue + } + refField = refField.Elem() + } + refField = refField.Field(field.Index) + } else { + refField = ref.Field(field.Index) + } + + if !field.Required && (!refField.IsValid() || refField.IsZero()) { + continue + } + + var value any + if refField.Kind() == reflect.Ptr { + if refField.IsNil() { + value = nil + } else { + value = refField.Elem().Interface() + } + } else { + value = refField.Interface() + } + + for j := 0; j < len(field.Tags); j++ { + tag := &field.Tags[j] + + rule, ok := r.Resolve(tag.Name) + if !ok { + return fmt.Errorf("validator `%s` not found for field `%s`", tag.Name, field.Name) + } + + if err = rule.Handle.ValidateHandle(ctx, value, tag.Opts...); err != nil { + return fmt.Errorf("validate field `%s`: %w", field.Name, err) + } + } + } + + return nil +} + +func (v *structValidator) getStructInfo(ref reflect.Type) ([]structFieldInfo, error) { + sfi, ok := v.cache.Get(ref) + if ok { + return sfi, nil + } + + v.mux.Lock() + defer v.mux.Unlock() + + sfi, ok = v.cache.Get(ref) + if ok { + return sfi, nil + } + + return v.resolveStructInfo(ref) +} + +func (v *structValidator) resolveStructInfo(ref reflect.Type) ([]structFieldInfo, error) { + sfi, ok := v.cache.Get(ref) + if ok { + return sfi, nil + } + + sfi = make([]structFieldInfo, 0, ref.NumField()) + + for i := 0; i < ref.NumField(); i++ { + + field := ref.Field(i) + + list, req, ok := v.resolveTag(field.Tag) + if !ok { + ft := field.Type + + if ft.Kind() == reflect.Ptr { + ft = field.Type.Elem() + } + + if ft.Kind() == reflect.Struct { + sub, err := v.resolveStructInfo(ft) + if err != nil { + return nil, err + } + + for _, info := range sub { + item := structFieldInfo{ + Name: info.Name, + Index: info.Index, + ParentIndex: i, + HasParent: true, + Required: info.Required, + Tags: info.Tags, + } + sfi = append(sfi, item) + } + } + + continue + } + + if !field.IsExported() { + return nil, fmt.Errorf("`%s` is not exported", field.Name) + } + + sfi = append(sfi, structFieldInfo{ + Name: ref.Name() + "." + field.Name, + Index: i, + Required: req, + Tags: list, + }) + } + + v.cache.Set(ref, sfi) + + return sfi, nil +} + +func (v *structValidator) resolveTag(l tagLookup) ([]tagInfo, bool, bool) { + valueStr, ok := l.Lookup(tagName) + if !ok { + return nil, false, false + } + + var required bool + tags := make([]tagInfo, 0, 2) + + for item := range strings.SplitSeq(valueStr, ";") { + switch item { + case "required": + required = true + continue + + case "": + continue + + default: + if len(item) == 0 { + continue + } + + ti := tagInfo{} + + inx := strings.IndexByte(item, '=') + if inx < 0 { + ti.Name = Name(item) + } else { + ti.Name = Name(item[:inx]) + + for s := range strings.SplitSeq(item[inx+1:], ",") { + ti.Opts = append(ti.Opts, s) + } + } + + tags = append(tags, ti) + } + } + + return tags, required, true +} + +func (v *structValidator) refStruct(arg any) (reflect.Value, error) { + ref := reflect.ValueOf(arg) + + for ref.Kind() == reflect.Ptr { + if ref.IsNil() { + return reflect.Value{}, fmt.Errorf("got nil-pointer object") + } + ref = ref.Elem() + } + + if ref.Kind() != reflect.Struct { + return reflect.Value{}, fmt.Errorf("got non-struct object") + } + + return ref, nil +} diff --git a/validate_struct_test.go b/validate_struct_test.go new file mode 100644 index 0000000..8396d54 --- /dev/null +++ b/validate_struct_test.go @@ -0,0 +1,173 @@ +package validate + +import ( + "context" + "errors" + "testing" +) + +func TestValidator_ValidateStruct(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + rules []Rule + input any + wantErr bool + errString string + }{ + { + name: "success simple", + rules: []Rule{ + {Name: "nonempty", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + s, ok := value.(string) + if !ok || s == "" { + return errors.New("must be non-empty") + } + return nil + })}, + }, + input: struct { + Name string `validate:"nonempty"` + }{Name: "test"}, + wantErr: false, + }, + { + name: "required field missing", + rules: []Rule{ + {Name: "nonempty", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + s, ok := value.(string) + if !ok || s == "" { + return errors.New("must be non-empty") + } + return nil + })}, + }, + input: struct { + Name string `validate:"required;nonempty"` + }{Name: ""}, + wantErr: true, + }, + { + name: "required field zero but optional ok", + rules: []Rule{ + {Name: "nonempty", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + return nil + })}, + }, + input: struct { + Name string `validate:"required;nonempty"` + }{Name: ""}, + wantErr: false, + }, + { + name: "rule not found", + rules: []Rule{ + {Name: "exists", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil })}, + }, + input: struct { + Field string `validate:"unknown"` + }{Field: "test"}, + wantErr: true, + errString: "validator `unknown` not found for field `.Field`", + }, + { + name: "non-exported field", + rules: []Rule{}, + input: struct { + hidden string `validate:"something"` + }{}, + wantErr: true, + errString: "`hidden` is not exported", + }, + { + name: "multiple rules on one field", + rules: []Rule{ + {Name: "minLen", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + s, _ := value.(string) + if len(s) < 2 { + return errors.New("too short") + } + return nil + })}, + {Name: "maxLen", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + s, _ := value.(string) + if len(s) > 10 { + return errors.New("too long") + } + return nil + })}, + }, + input: struct { + Field string `validate:"minLen;maxLen"` + }{Field: "foo"}, + wantErr: false, + }, + { + name: "first rule fails", + rules: []Rule{ + {Name: "minLen", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + return errors.New("minLen failed") + })}, + {Name: "maxLen", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil })}, + }, + input: struct { + Field string `validate:"minLen;maxLen"` + }{Field: "foo"}, + wantErr: true, + }, + { + name: "nil pointer to struct", + rules: []Rule{}, + input: (*struct{ Name string })(nil), + wantErr: true, + }, + { + name: "pointer to struct", + rules: []Rule{ + {Name: "nonempty", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + if value.(string) == "" { + return errors.New("empty") + } + return nil + })}, + }, + input: &struct { + Tag string `validate:"nonempty"` + }{Tag: "ok"}, + wantErr: false, + }, + { + name: "non-struct input", + rules: []Rule{}, + input: 42, + wantErr: true, + }, + { + name: "required with zero value but handler returns nil", + rules: []Rule{ + {Name: "check", Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil })}, + }, + input: struct { + ID int `validate:"required;check"` + }{ID: 0}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := New() + if err := v.Register(tt.rules...); err != nil { + t.Fatalf("Register() error = %v", err) + } + err := v.ValidateStruct(ctx, tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateStruct() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && tt.errString != "" && err.Error() != tt.errString { + t.Errorf("expected error %q, got %q", tt.errString, err.Error()) + } + }) + } +} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000..5cf5b5f --- /dev/null +++ b/validator.go @@ -0,0 +1,32 @@ +package validate + +import "context" + +type Validator struct { + store *store + structVld *structValidator +} + +func New() *Validator { + return &Validator{ + store: newStore(), + structVld: newStructValidator(), + } +} + +func (v *Validator) Register(rules ...Rule) error { + return v.store.Append(rules...) +} + +func (v *Validator) ValidateStruct(ctx context.Context, arg any) error { + return v.structVld.run(ctx, v.store, arg) +} + +func (v *Validator) Validate(ctx context.Context, call func(c Callback)) error { + cb := poolCallbackValidator.Get() + defer poolCallbackValidator.Put(cb) + + call(cb) + + return cb.run(ctx, v.store) +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..15c03f0 --- /dev/null +++ b/validator_test.go @@ -0,0 +1,148 @@ +package validate + +import ( + "context" + "fmt" + "testing" +) + +func TestValidator_Register(t *testing.T) { + v := New() + if err := v.Register(Rule{}); err == nil { + t.Fatalf("Register(): empty rule") + } + + if err := v.Register( + Rule{ + Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil }), + }); err == nil { + t.Fatalf("Register(): rule without name") + } + + if err := v.Register( + Rule{ + Name: "1", + Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil }), + }, + ); err != nil { + t.Fatalf("Register(): error = %v", err) + } + + if err := v.Register( + Rule{ + Name: "1", + Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { return nil }), + }, + ); err == nil { + t.Fatalf("Register(): duplicate rule name") + } +} + +/* +goos: linux +goarch: amd64 +pkg: go.osspkg.com/validate +cpu: 12th Gen Intel(R) Core(TM) i9-12900KF +Benchmark_Validate +Benchmark_Validate-24 7162855 165.8 ns/op 48 B/op 3 allocs/op +*/ +func Benchmark_Validate(b *testing.B) { + v := New() + v.Register( + Rule{ + Name: "eq", + Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + intVal, ok := value.(int) + if !ok { + return fmt.Errorf("expected int, got %T", value) + } + if len(opts) != 1 { + return fmt.Errorf("expected 1 options, got %d", len(opts)) + } + argVal, ok := opts[0].(int) + if !ok { + return fmt.Errorf("expected int, got %T", opts[0]) + } + if intVal != argVal { + return fmt.Errorf("expected %d, got %d", intVal, argVal) + } + return nil + }), + }, + ) + + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + err := v.Validate(ctx, func(cb Callback) { + cb.Require("eq", 1, 1) + cb.Require("eq", 2, 2) + cb.Optional("eq", 0, 2) + }) + if err != nil { + b.Fatal(err) + } + } + }) +} + +/* +goos: linux +goarch: amd64 +pkg: go.osspkg.com/validate +cpu: 12th Gen Intel(R) Core(TM) i9-12900KF +Benchmark_ValidateStruct +Benchmark_ValidateStruct-24 10004199 118.1 ns/op 16 B/op 2 allocs/op +*/ +func Benchmark_ValidateStruct(b *testing.B) { + v := New() + v.Register( + Rule{ + Name: "eq", + Handle: HandlerFunc(func(ctx context.Context, value any, opts ...any) error { + intVal, ok := value.(int) + if !ok { + return fmt.Errorf("expected int, got %T", value) + } + if len(opts) != 1 { + return fmt.Errorf("expected 1 options, got %d", len(opts)) + } + var argVal int + if err := StringDecode(&argVal, opts[0].(string)); err != nil { + return err + } + if intVal != argVal { + return fmt.Errorf("expected %d, got %d", argVal, intVal) + } + return nil + }), + }, + ) + + type mock struct { + Id1 int `validate:"required;eq=1"` + Id2 int `validate:"required;eq=2"` + Id3 int `validate:"eq=2"` + } + + mod := &mock{ + Id1: 1, + Id2: 2, + Id3: 0, + } + + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := v.ValidateStruct(ctx, mod); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/ver.go b/version/ver.go similarity index 84% rename from ver.go rename to version/ver.go index ca1b9c5..2b94daa 100644 --- a/ver.go +++ b/version/ver.go @@ -3,7 +3,7 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ -package validate +package version import ( "fmt" @@ -23,7 +23,7 @@ func (v Version) String() string { return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) } -func ParseVersion(v string) (*Version, error) { +func Parse(v string) (*Version, error) { data := rex.FindStringSubmatch(v) if len(data) != 4 { return nil, fmt.Errorf("invalid format: %s", v) @@ -46,9 +46,9 @@ func ParseVersion(v string) (*Version, error) { return result, nil } -func CompareVersion(v1, v2 string) int { - a, e1 := ParseVersion(v1) - b, e2 := ParseVersion(v2) +func Compare(v1, v2 string) int { + a, e1 := Parse(v1) + b, e2 := Parse(v2) switch true { case e1 != nil && e2 != nil: return 0 @@ -73,14 +73,14 @@ func CompareVersion(v1, v2 string) int { } } -func MaxVersion(versions ...string) *Version { +func GetMax(versions ...string) *Version { result := "v0.0.0" for _, ver := range versions { - if CompareVersion(result, ver) < 0 { + if Compare(result, ver) < 0 { result = ver } } - v, err := ParseVersion(result) + v, err := Parse(result) if err != nil { return &Version{ Major: 0, diff --git a/ver_test.go b/version/ver_test.go similarity index 80% rename from ver_test.go rename to version/ver_test.go index b190b36..2f1af55 100644 --- a/ver_test.go +++ b/version/ver_test.go @@ -3,7 +3,7 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ -package validate +package version import ( "reflect" @@ -40,13 +40,13 @@ func TestUnit_ParseVersion(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseVersion(tt.args) + got, err := Parse(tt.args) if (err != nil) != tt.wantErr { - t.Errorf("ParseVersion() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { - t.Errorf("ParseVersion() got = %v, want %v", got, tt.want) + t.Errorf("Parse() got = %v, want %v", got, tt.want) } }) } @@ -76,8 +76,8 @@ func TestUnit_MaxVersion(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if gotOut := MaxVersion(tt.vers...); gotOut.String() != tt.wantOut { - t.Errorf("MaxVersion() = %v, want %v", gotOut, tt.wantOut) + if gotOut := GetMax(tt.vers...); gotOut.String() != tt.wantOut { + t.Errorf("GetMax() = %v, want %v", gotOut, tt.wantOut) } }) } From 39a5307ecfbece3c5ffddbafbe73cf91efeec8ff Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 26 May 2026 02:56:17 +0300 Subject: [PATCH 2/2] add custom validator --- .github/workflows/ci.yml | 2 +- .golangci.yml | 389 +++---------------------- LICENSE | 2 +- cmd/govld/main.go | 11 +- common.go | 5 + convert.go | 5 + convert_test.go | 5 + domain/domain.go | 2 +- domain/domain_test.go | 2 +- example/internal/adapt_handlers_gen.go | 14 +- example/internal/example.go | 5 + example/internal/example_test.go | 5 + handle_adapt.go | 5 + handle_adapt_test.go | 5 + internal/cache/cache.go | 5 + internal/pool/pool.go | 2 +- internal/util/util.go | 5 + store.go | 5 + validate_callback.go | 5 + validate_callback_test.go | 5 + validate_struct.go | 5 + validate_struct_test.go | 5 + validator.go | 5 + validator_test.go | 5 + version/ver.go | 2 +- version/ver_test.go | 2 +- 26 files changed, 146 insertions(+), 362 deletions(-) mode change 100644 => 100755 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ddfece..fb73c3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: [ '1.21' ] + go: [ '1.25' ] steps: - uses: actions/checkout@v3 diff --git a/.golangci.yml b/.golangci.yml old mode 100644 new mode 100755 index f4b5f9a..2da416b --- a/.golangci.yml +++ b/.golangci.yml @@ -1,375 +1,68 @@ +version: "2" -# options for analysis running run: - # timeout for analysis, e.g. 30s, 5m, default is 1m - deadline: 5m - - # exit code when at least one issue was found, default is 1 + go: "1.25" + timeout: 5m + tests: false issues-exit-code: 1 - - # include test files or not, default is true - tests: true - - # which files to skip: they will be analyzed, but issues from them - # won't be reported. Default value is empty list, but there is - # no need to include all autogenerated files, we confidently recognize - # autogenerated files. If it's not please let us know. - skip-files: - - easyjson + modules-download-mode: readonly + allow-parallel-runners: true issues: - # Independently from option 'exclude' we use default exclude patterns, - # it can be disabled by this option. To list all - # excluded by default patterns execute 'golangci-lint run --help'. - # Default value for this option is true. - exclude-use-default: false - # Excluding configuration per-path, per-linter, per-text and per-source - exclude-rules: - # Exclude some linters from running on tests files. - - path: _test\.go - linters: - - prealloc - - errcheck + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: false -# output configuration options output: - # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number" - format: colored-line-number - - # print lines of code with issue, default is true - print-issued-lines: true - - # print linter name in the end of issue text, default is true - print-linter-name: true + formats: + text: + print-linter-name: true + print-issued-lines: true -# all available settings of specific linters -linters-settings: - govet: - # report about shadowed variables - check-shadowing: true +formatters: + exclusions: + paths: + - vendors/ enable: - # report mismatches between assembly files and Go declarations - - asmdecl - # check for useless assignments - - assign - # check for common mistakes using the sync/atomic package - - atomic - # check for non-64-bits-aligned arguments to sync/atomic functions - - atomicalign - # check for common mistakes involving boolean operators - - bools - # check that +build tags are well-formed and correctly located - - buildtag - # detect some violations of the cgo pointer passing rules - - cgocall - # check for unkeyed composite literals - - composites - # check for locks erroneously passed by value - - copylocks - # check for calls of reflect.DeepEqual on error values - - deepequalerrors - # report passing non-pointer or non-error values to errors.As - - errorsas - # find calls to a particular function - - findcall - # report assembly that clobbers the frame pointer before saving it - - framepointer - # check for mistakes using HTTP responses - - httpresponse - # detect imposMaxsible interface-to-interface type assertions - - ifaceassert - # check references to loop variables from within nested functions - - loopclosure - # check cancel func returned by context.WithCancel is called - - lostcancel - # check for useless comparisons between functions and nil - - nilfunc - # check for redundant or impossible nil comparisons - - nilness - # check consistency of Printf format strings and arguments - - printf - # check for comparing reflect.Value values with == or reflect.DeepEqual - - reflectvaluecompare - # check for possible unintended shadowing of variables - - shadow - # check for shifts that equal or exceed the width of the integer - - shift - # check for unbuffered channel of os.Signal - - sigchanyzer - # check the argument type of sort.Slice - - sortslice - # check signature of methods of well-known interfaces - - stdmethods - # check for string(int) conversions - - stringintconv - # check that struct field tags conform to reflect.StructTag.Get - - structtag - # report calls to (*testing.T).Fatal from goroutines started by a test. - - testinggoroutine - # check for common mistaken usages of tests and examples - - tests - # report passing non-pointer or non-interface values to unmarshal - - unmarshal - # check for unreachable code - - unreachable - # check for invalid conversions of uintptr to unsafe.Pointer - - unsafeptr - # check for unused results of calls to some functions - - unusedresult - # checks for unused writes - - unusedwrite - disable: - # find structs that would use less memory if their fields were sorted - - fieldalignment - gofmt: - # simplify code: gofmt with '-s' option, true by default - simplify: true - errcheck: - # report about not checking of errors in type assetions: 'a := b.(MyStruct)'; - # default is false: such cases aren't reported by default. - check-type-assertions: true - # report about assignment of errors to blank identifier: 'num, _ := strconv.Atoi(numStr)'; - # default is false: such cases aren't reported by default. - check-blank: true - gocyclo: - # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 18 - misspell: - # Correct spellings using locale preferences for US or UK. - # Default is to use a neutral variety of English. - # Setting locale to US will correct the British spelling of 'colour' to 'color'. - locale: US - prealloc: - # XXX: we don't recommend using this linter before doing performance profiling. - # For most programs usage of prealloc will be a premature optimization. - # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. - # True by default. - simple: true - range-loops: true # Report preallocation suggestions on range loops, true by default - for-loops: true # Report preallocation suggestions on for loops, false by default - unparam: - # Inspect exported functions, default is false. Set to true if no external program/library imports your code. - # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: - # if it's called for subdir of a project it can't find external interfaces. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - gci: - # Section configuration to compare against. - # Section names are case-insensitive and may contain parameters in (). - # The default order of sections is 'standard > default > custom > blank > dot', - # If 'custom-order' is 'true', it follows the order of 'sections' option. - # Default: ["standard", "default"] - #sections: - #- standard # Standard section: captures all standard packages. - #- default # Default section: contains all imports that could not be matched to another section type. - #- blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. - #- dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. - # Skip generated files. - # Default: true - skip-generated: true - # Enable custom order of sections. - # If 'true', make the section order the same as the order of 'sections'. - # Default: false - custom-order: false - gosec: - # To select a subset of rules to run. - # Available rules: https://github.com/securego/gosec#available-rules - # Default: [] - means include all rules - includes: - - G101 # Look for hard coded credentials - - G102 # Bind to all interfaces - - G103 # Audit the use of unsafe block - - G104 # Audit errors not checked - - G106 # Audit the use of ssh.InsecureIgnoreHostKey - - G107 # Url provided to HTTP request as taint input - - G108 # Profiling endpoint automatically exposed on /debug/pprof - - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - - G110 # Potential DoS vulnerability via decompression bomb - - G111 # Potential directory traversal - - G112 # Potential slowloris attack - - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) - - G114 # Use of net/http serve function that has no support for setting timeouts - - G201 # SQL query construction using format string - - G202 # SQL query construction using string concatenation - - G203 # Use of unescaped data in HTML templates - - G204 # Audit use of command execution - - G301 # Poor file permissions used when creating a directory - - G302 # Poor file permissions used with chmod - - G303 # Creating tempfile using a predictable path - - G304 # File path provided as taint input - - G305 # File traversal when extracting zip/tar archive - - G306 # Poor file permissions used when writing to a new file - - G307 # Deferring a method which returns an error - - G401 # Detect the usage of DES, RC4, MD5 or SHA1 - - G402 # Look for bad TLS connection settings - - G403 # Ensure minimum RSA key length of 2048 bits - - G404 # Insecure random number source (rand) - - G501 # Import blocklist: crypto/md5 - - G502 # Import blocklist: crypto/des - - G503 # Import blocklist: crypto/rc4 - - G504 # Import blocklist: net/http/cgi - - G505 # Import blocklist: crypto/sha1 - - G601 # Implicit memory aliasing of items from a range statement - # To specify a set of rules to explicitly exclude. - # Available rules: https://github.com/securego/gosec#available-rules - # Default: [] - excludes: - - G101 # Look for hard coded credentials - - G102 # Bind to all interfaces - - G103 # Audit the use of unsafe block - - G104 # Audit errors not checked - - G106 # Audit the use of ssh.InsecureIgnoreHostKey - - G107 # Url provided to HTTP request as taint input - - G108 # Profiling endpoint automatically exposed on /debug/pprof - - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - - G110 # Potential DoS vulnerability via decompression bomb - - G111 # Potential directory traversal - - G112 # Potential slowloris attack - - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) - - G114 # Use of net/http serve function that has no support for setting timeouts - - G201 # SQL query construction using format string - - G202 # SQL query construction using string concatenation - - G203 # Use of unescaped data in HTML templates - - G204 # Audit use of command execution - - G301 # Poor file permissions used when creating a directory - - G302 # Poor file permissions used with chmod - - G303 # Creating tempfile using a predictable path - - G304 # File path provided as taint input - - G305 # File traversal when extracting zip/tar archive - - G306 # Poor file permissions used when writing to a new file - - G307 # Deferring a method which returns an error - - G401 # Detect the usage of DES, RC4, MD5 or SHA1 - - G402 # Look for bad TLS connection settings - - G403 # Ensure minimum RSA key length of 2048 bits - - G404 # Insecure random number source (rand) - - G501 # Import blocklist: crypto/md5 - - G502 # Import blocklist: crypto/des - - G503 # Import blocklist: crypto/rc4 - - G504 # Import blocklist: net/http/cgi - - G505 # Import blocklist: crypto/sha1 - - G601 # Implicit memory aliasing of items from a range statement - # Exclude generated files - # Default: false - exclude-generated: true - # Filter out the issues with a lower severity than the given value. - # Valid options are: low, medium, high. - # Default: low - severity: medium - # Filter out the issues with a lower confidence than the given value. - # Valid options are: low, medium, high. - # Default: low - confidence: medium - # Concurrency value. - # Default: the number of logical CPUs usable by the current process. - concurrency: 12 - # To specify the configuration of rules. - config: - # Globals are applicable to all rules. - global: - # If true, ignore #nosec in comments (and an alternative as well). - # Default: false - nosec: true - # Add an alternative comment prefix to #nosec (both will work at the same time). - # Default: "" - "#nosec": "#my-custom-nosec" - # Define whether nosec issues are counted as finding or not. - # Default: false - show-ignored: true - # Audit mode enables addition checks that for normal code analysis might be too nosy. - # Default: false - audit: true - G101: - # Regexp pattern for variables and constants to find. - # Default: "(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred" - pattern: "(?i)example" - # If true, complain about all cases (even with low entropy). - # Default: false - ignore_entropy: false - # Maximum allowed entropy of the string. - # Default: "80.0" - entropy_threshold: "80.0" - # Maximum allowed value of entropy/string length. - # Is taken into account if entropy >= entropy_threshold/2. - # Default: "3.0" - per_char_threshold: "3.0" - # Calculate entropy for first N chars of the string. - # Default: "16" - truncate: "32" - # Additional functions to ignore while checking unhandled errors. - # Following functions always ignored: - # bytes.Buffer: - # - Write - # - WriteByte - # - WriteRune - # - WriteString - # fmt: - # - Print - # - Printf - # - Println - # - Fprint - # - Fprintf - # - Fprintln - # strings.Builder: - # - Write - # - WriteByte - # - WriteRune - # - WriteString - # io.PipeWriter: - # - CloseWithError - # hash.Hash: - # - Write - # os: - # - Unsetenv - # Default: {} - G104: - fmt: - - Fscanf - G111: - # Regexp pattern to find potential directory traversal. - # Default: "http\\.Dir\\(\"\\/\"\\)|http\\.Dir\\('\\/'\\)" - pattern: "custom\\.Dir\\(\\)" - # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll - # Default: "0750" - G301: "0750" - # Maximum allowed permissions mode for os.OpenFile and os.Chmod - # Default: "0600" - G302: "0600" - # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile - # Default: "0600" - G306: "0600" - - lll: - # Max line length, lines longer will be reported. - # '\t' is counted as 1 character by default, and can be changed with the tab-width option. - # Default: 120. - line-length: 120 - # Tab width in spaces. - # Default: 1 - tab-width: 1 + - gofmt + - goimports linters: - disable-all: true + settings: + staticcheck: + checks: + - all + - -S1023 + - -ST1000 + - -ST1003 + - -ST1020 + gosec: + excludes: + - G104 + - G115 + - G301 + - G304 + - G306 + - G501 + - G505 + exclusions: + paths: + - vendors/ + default: none enable: - govet - - gofmt - errcheck - misspell - gocyclo - ineffassign - - goimports - - nakedret - unparam - unused - prealloc - durationcheck - - nolintlint - staticcheck - makezero - nilerr - errorlint - bodyclose - - exportloopref - - gci - gosec - - lll - fast: false diff --git a/LICENSE b/LICENSE index 1fd5dac..d6c3bf3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2024, Mikhail Knyazhev +Copyright (c) 2024-2026, Mikhail Knyazhev Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/cmd/govld/main.go b/cmd/govld/main.go index 92539f9..1fdd4d5 100644 --- a/cmd/govld/main.go +++ b/cmd/govld/main.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package main import ( @@ -114,7 +119,7 @@ func (g *generator) makeQualifier() types.Qualifier { func (g *generator) generate(filename string) error { var buf bytes.Buffer - buf.WriteString("// Code generated by govld-gen; DO NOT EDIT.\n\n") + buf.WriteString("// Code generated by govld; DO NOT EDIT.\n\n") buf.WriteString(fmt.Sprintf("package %s\n\n", g.pkg.Name)) var funcBodies bytes.Buffer @@ -161,9 +166,9 @@ func (g *generator) generate(filename string) error { alias = pkgNameFromPath(p) } if alias == g.pkg.Name && p != g.pkg.PkgPath { - alias = alias + "_import" + alias = alias + "pkg" //nolint:ineffassign,staticcheck } - importLines = append(importLines, fmt.Sprintf("\t\"%s\"", p)) + importLines = append(importLines, fmt.Sprintf("\t%s \"%s\"", alias, p)) } importLines = append(importLines, ")") diff --git a/common.go b/common.go index 906a2b4..7662565 100644 --- a/common.go +++ b/common.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/convert.go b/convert.go index b4c4888..76bc748 100644 --- a/convert.go +++ b/convert.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/convert_test.go b/convert_test.go index 28c0f82..628bb44 100644 --- a/convert_test.go +++ b/convert_test.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/domain/domain.go b/domain/domain.go index 8f27a9b..536d7af 100644 --- a/domain/domain.go +++ b/domain/domain.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Mikhail Knyazhev . All rights reserved. + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ diff --git a/domain/domain_test.go b/domain/domain_test.go index f1f1154..c434794 100644 --- a/domain/domain_test.go +++ b/domain/domain_test.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Mikhail Knyazhev . All rights reserved. + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ diff --git a/example/internal/adapt_handlers_gen.go b/example/internal/adapt_handlers_gen.go index 3fddf6c..61918e7 100644 --- a/example/internal/adapt_handlers_gen.go +++ b/example/internal/adapt_handlers_gen.go @@ -1,11 +1,17 @@ -// Code generated by govld-gen; DO NOT EDIT. +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Code generated by govld; DO NOT EDIT. package internal import ( - "context" - "fmt" - "go.osspkg.com/validate" + context "context" + fmt "fmt" + + validate "go.osspkg.com/validate" ) // ValidateUIDAdaptHandler is adapter for ValidateUID. diff --git a/example/internal/example.go b/example/internal/example.go index 0869cd1..f5fdaf8 100644 --- a/example/internal/example.go +++ b/example/internal/example.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package internal import ( diff --git a/example/internal/example_test.go b/example/internal/example_test.go index 1d746d8..df37c9f 100644 --- a/example/internal/example_test.go +++ b/example/internal/example_test.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package internal import ( diff --git a/handle_adapt.go b/handle_adapt.go index 5ef350a..1f02ff4 100644 --- a/handle_adapt.go +++ b/handle_adapt.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/handle_adapt_test.go b/handle_adapt_test.go index 5290ed7..0e8b65c 100644 --- a/handle_adapt_test.go +++ b/handle_adapt_test.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 549ae81..0f4331d 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package cache import "sync" diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 6be02d0..198d25c 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Mikhail Knyazhev . All rights reserved. + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ diff --git a/internal/util/util.go b/internal/util/util.go index 8b1afe9..1a55e56 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package util import "reflect" diff --git a/store.go b/store.go index df1a24e..bba918b 100644 --- a/store.go +++ b/store.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/validate_callback.go b/validate_callback.go index 8c0900f..3ea028a 100644 --- a/validate_callback.go +++ b/validate_callback.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/validate_callback_test.go b/validate_callback_test.go index 402bf12..934fddc 100644 --- a/validate_callback_test.go +++ b/validate_callback_test.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/validate_struct.go b/validate_struct.go index 91174fa..3142361 100644 --- a/validate_struct.go +++ b/validate_struct.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/validate_struct_test.go b/validate_struct_test.go index 8396d54..8206588 100644 --- a/validate_struct_test.go +++ b/validate_struct_test.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/validator.go b/validator.go index 5cf5b5f..310d9a3 100644 --- a/validator.go +++ b/validator.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import "context" diff --git a/validator_test.go b/validator_test.go index 15c03f0..62405b4 100644 --- a/validator_test.go +++ b/validator_test.go @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + package validate import ( diff --git a/version/ver.go b/version/ver.go index 2b94daa..c8f4aa1 100644 --- a/version/ver.go +++ b/version/ver.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Mikhail Knyazhev . All rights reserved. + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ diff --git a/version/ver_test.go b/version/ver_test.go index 2f1af55..d89e6ea 100644 --- a/version/ver_test.go +++ b/version/ver_test.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Mikhail Knyazhev . All rights reserved. + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */