package main
import (
_ "embed"
"flag"
"fmt"
"log/slog"
"os"
"strings"
"github.com/nobl9/govy/pkg/govyconfig"
)
const (
govyCmdName = "govy"
nameInferCmdName = "nameinfer"
)
var subcommands = []string{
nameInferCmdName,
}
func main() {
govyconfig.SetLogLevel(slog.LevelDebug)
rootCmd := flag.NewFlagSet(govyCmdName, flag.ExitOnError)
rootCmd.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", govyCmdName)
fmt.Fprintf(os.Stderr, " %s <subcommand> [flags]\n", govyCmdName)
fmt.Fprintf(os.Stderr, "Subcommands:\n")
for _, cmd := range subcommands {
fmt.Fprintf(os.Stderr, " %s\n", cmd)
}
}
if len(os.Args) < 2 {
rootCmd.Usage()
os.Exit(1)
}
var cmd interface{ Run() error }
switch os.Args[1] {
case nameInferCmdName:
cmd = newNameInferCommand()
default:
errFatalWithUsage(
rootCmd,
"'%s' is not a valid subcommand, try: %s",
os.Args[1],
strings.Join(subcommands, ", "),
)
return
}
if err := cmd.Run(); err != nil {
errFatal(err.Error())
}
}
func errFatalWithUsage(cmd *flag.FlagSet, f string, a ...any) {
f = "Error: " + f
if len(a) == 0 {
fmt.Fprintln(os.Stderr, f)
} else {
fmt.Fprintf(os.Stderr, f+"\n", a...)
}
cmd.Usage()
os.Exit(1)
}
func errFatal(f string, a ...any) {
f = "Error: " + f
if len(a) == 0 {
fmt.Fprintln(os.Stderr, f)
} else {
fmt.Fprintf(os.Stderr, f+"\n", a...)
}
os.Exit(1)
}
package main
import (
"bytes"
_ "embed"
"errors"
"flag"
"fmt"
"go/ast"
"go/format"
"os"
"path/filepath"
"slices"
"strings"
"text/template"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/nameinfer"
"github.com/nobl9/govy/pkg/govyconfig"
)
type nameInferTemplateData struct {
ProgramInvocation string
Package string
Names map[string]govyconfig.InferredName
}
//go:embed inferred_names.go.tmpl
var inferredNamesTemplateStr string
var inferredNamesTemplate = template.Must(
template.New("inferred_names").Parse(inferredNamesTemplateStr))
func newNameInferCommand() *nameInferCommand {
fset := flag.NewFlagSet(nameInferCmdName, flag.ExitOnError)
cmd := &nameInferCommand{fset: fset}
fset.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s %s:\n", govyCmdName, nameInferCmdName)
fmt.Fprintf(os.Stderr, " %s %s [flags]\n", govyCmdName, nameInferCmdName)
fmt.Fprintf(os.Stderr, "Flags:\n")
fset.PrintDefaults()
}
fset.StringVar(&cmd.outputDir, "dir", "", "directory path to save the generated file")
fset.StringVar(&cmd.pkg, "pkg", "", "package name of the generated file")
fset.StringVar(&cmd.fileName, "filename", "govy_inferred_names.go", "generated file name")
return cmd
}
type nameInferCommand struct {
fset *flag.FlagSet
outputDir string
pkg string
fileName string
}
func (n *nameInferCommand) Run() error {
_ = n.fset.Parse(os.Args[2:])
if n.outputDir == "" {
errFatalWithUsage(n.fset, "'-dir' flag is required")
}
if n.pkg == "" {
errFatalWithUsage(n.fset, "'-pkg' flag is required")
}
root := internal.FindModuleRoot()
if root == "" {
return errors.New("failed to find module root")
}
modAST := nameinfer.NewModuleAST(root)
names := make(map[string]govyconfig.InferredName)
for _, pkg := range modAST.Packages {
for i, f := range pkg.Syntax {
importName := nameinfer.GetGovyImportName(f)
ast.Inspect(f, func(n ast.Node) bool {
selectorExpr, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
exprIdent, ok := selectorExpr.X.(*ast.Ident)
if !ok {
return true
}
if exprIdent.Name != importName ||
!slices.Contains(nameinfer.FunctionsWithGetter, selectorExpr.Sel.Name) {
return true
}
line := modAST.FileSet.Position(selectorExpr.Pos()).Line
inferredName := nameinfer.InferNameFromFile(modAST.FileSet, pkg, f, line)
name := govyconfig.InferredName{
Name: inferredName,
File: strings.TrimPrefix(pkg.GoFiles[i], root+"/"),
Line: line,
}
fmt.Printf("Found 'govy.%s' function at: %s:%d\n", selectorExpr.Sel.Name, name.File, name.Line)
key := fmt.Sprintf("%s:%d", name.File, name.Line)
names[key] = name
return false
})
}
}
if len(names) == 0 {
errFatal("no names inferred")
}
buf := new(bytes.Buffer)
if err := inferredNamesTemplate.Execute(buf, nameInferTemplateData{
ProgramInvocation: fmt.Sprintf("%s %s", govyCmdName, strings.Join(os.Args[1:], " ")),
Package: n.pkg,
Names: names,
}); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
formatted, err := format.Source(buf.Bytes())
if err != nil {
return fmt.Errorf("failed to format produced template: %w", err)
}
outputName := filepath.Join(n.outputDir, n.fileName)
if err = os.WriteFile(outputName, formatted, 0o600); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
package assert
import (
"reflect"
"strings"
"testing"
)
// Fail fails the test with the provided message.
func Fail(t testing.TB, msg string, a ...interface{}) bool {
t.Helper()
t.Errorf(msg, a...)
return false
}
// Require fails the test if the provided boolean is false.
// It should be used in conjunction with assert functions.
// Example:
//
// assert.Require(t, assert.AssertError(t, err))
func Require(t testing.TB, isPassing bool) {
t.Helper()
if !isPassing {
t.FailNow()
}
}
// Equal fails the test if the expected and actual values are not equal.
func Equal(t testing.TB, expected, actual interface{}) bool {
t.Helper()
if !areEqual(expected, actual) {
return Fail(t, "Expected: %v\nActual: %v", expected, actual)
}
return true
}
// True fails the test if the actual value is not true.
func True(t testing.TB, actual bool) bool {
t.Helper()
if !actual {
return Fail(t, "Should be true")
}
return true
}
// False fails the test if the actual value is not false.
func False(t testing.TB, actual bool) bool {
t.Helper()
if actual {
return Fail(t, "Should be false")
}
return true
}
// Len fails the test if the object is not of the expected length.
func Len(t testing.TB, object interface{}, length int) bool {
t.Helper()
if actual := getLen(object); actual != length {
return Fail(t, "Expected length: %d\nActual: %d", length, actual)
}
return true
}
// IsType fails the test if the object is not of the expected type.
// The expected type is specified using a type parameter.
func IsType[T any](t testing.TB, object interface{}) bool {
t.Helper()
switch object.(type) {
case T:
return true
default:
return Fail(t, "Expected type: %T\nActual: %T", *new(T), object)
}
}
// Error fails the test if the error is nil.
func Error(t testing.TB, err error) bool {
t.Helper()
if err == nil {
return Fail(t, "An error is expected but actual nil.")
}
return true
}
// NoError fails the test if the error is not nil.
func NoError(t testing.TB, err error) bool {
t.Helper()
if err != nil {
return Fail(t, "Unexpected error:\n%+v", err)
}
return true
}
// EqualError fails the test if the expected error is not equal to the actual error message.
func EqualError(t testing.TB, err error, expected string) bool {
t.Helper()
if !Error(t, err) {
return false
}
if err.Error() != expected {
return Fail(t, "Expected error message: %q\nActual: %q", expected, err.Error())
}
return true
}
// ErrorContains fails the test if the expected error does not contain the provided string.
func ErrorContains(t testing.TB, err error, contains string) bool {
t.Helper()
if !Error(t, err) {
return false
}
if !strings.Contains(err.Error(), contains) {
return Fail(t, "Expected error message to contain: %q\nActual: %q", contains, err.Error())
}
return true
}
// ElementsMatch fails the test if the expected and actual slices do not have the same elements.
func ElementsMatch[T comparable](t testing.TB, expected, actual []T) bool {
t.Helper()
if len(expected) != len(actual) {
return Fail(t, "Slices are not equal in length, expected: %d, actual: %d", len(expected), len(actual))
}
actualVisited := make([]bool, len(actual))
for _, e := range expected {
found := false
for j, a := range actual {
if actualVisited[j] {
continue
}
if areEqual(e, a) {
actualVisited[j] = true
found = true
break
}
}
if !found {
return Fail(t, "Expected element %v not found in actual slice", e)
}
}
for i := range actual {
if !actualVisited[i] {
return Fail(t, "Unexpected element %v found in actual slice", actual[i])
}
}
return true
}
// Panic checks that the function panics with the expected message.
func Panic(t testing.TB, f func(), expected string) (result bool) {
t.Helper()
defer func() {
r := recover()
if r == nil {
result = Fail(t, "Function did not panic")
return
}
result = Equal(t, expected, r)
}()
f()
return false
}
func areEqual(expected, actual interface{}) bool {
if expected == nil || actual == nil {
return expected == actual
}
if !reflect.DeepEqual(expected, actual) {
return false
}
return true
}
func getLen(v interface{}) int {
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Slice, reflect.Map, reflect.String:
return rv.Len()
default:
return -1
}
}
package main
import (
"context"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/collections"
"github.com/nobl9/govy/internal/logging"
)
const (
variableName = "templateFunctions"
firstComment = "// The following functions are made available for use in the templates:"
)
// docextractor is a tool that extracts documentation from builtin template functions
// and adds them to the AddTemplateFunctions function in the message_templates.go file.
func main() {
fmt.Println("Running docextractor...")
root := internal.FindModuleRoot()
docs := findTemplateFunctionsDocs(root)
path := filepath.Join(root, "pkg", "govy", "message_templates.go")
fileContents := readFile(path)
fset := token.NewFileSet()
astFile, err := parser.ParseFile(fset, path, fileContents, parser.ParseComments|parser.SkipObjectResolution)
if err != nil {
logFatal(err, "Failed to parse file AST %q", path)
}
ast.Inspect(astFile, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.FuncDecl:
if v.Name.Name != "AddTemplateFunctions" {
return true
}
addTemplateFunctionComments(docs, v)
return false
}
return true
})
file, err := os.Create(path) // #nosec G304
if err != nil {
logFatal(err, "Failed to open file %q", path)
}
defer func() { _ = file.Close() }()
if err = format.Node(file, fset, astFile); err != nil {
logFatal(err, "Failed to format and write file %q", path)
}
}
func addTemplateFunctionComments(docs [][]string, funcDecl *ast.FuncDecl) {
comments := funcDecl.Doc.List
appendComments := func(texts ...string) {
for _, text := range texts {
comments = append(comments, &ast.Comment{
Slash: funcDecl.Pos() - 1,
Text: text,
})
}
}
appendComments(firstComment)
for _, templateFuncDocsLines := range docs {
appendComments("//")
for i, line := range templateFuncDocsLines {
if i == 0 {
line = "// - " + line
} else {
line = "// " + line
}
appendComments(line)
}
}
appendComments(
"//",
"// Refer to the testable examples of [AddTemplateFunctions] for more details",
"// on each builtin function.",
)
funcDecl.Doc.List = comments
}
func readFile(path string) string {
fileContents, err := os.ReadFile(path) // #nosec G304
if err != nil {
logFatal(err, "Failed to read file %q contents", path)
}
fileContentsStr := string(fileContents)
if strings.Contains(fileContentsStr, firstComment) {
firstCommentIdx := strings.Index(fileContentsStr, firstComment)
funcIdx := strings.Index(fileContentsStr, "func AddTemplateFunctions(")
fileContentsStr = fileContentsStr[:firstCommentIdx] + fileContentsStr[funcIdx:]
}
return fileContentsStr
}
func findTemplateFunctionsDocs(root string) [][]string {
path := filepath.Join(root, "internal", "messagetemplates", "functions.go")
fset := token.NewFileSet()
astFile, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution)
if err != nil {
logFatal(err, "Failed to parse file %q", path)
}
var templateFunctionsExpr ast.Expr
ast.Inspect(astFile, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.ValueSpec:
if len(v.Names) == 1 && v.Names[0].Name == variableName &&
len(v.Values) == 1 {
logging.Logger().Debug(fmt.Sprintf("found variable: %s", v.Names[0].Name))
templateFunctionsExpr = v.Values[0]
return false
}
}
return true
})
if templateFunctionsExpr == nil {
logFatal(nil, "Template functions %q variable was not found", variableName)
}
compositeLiteral, ok := templateFunctionsExpr.(*ast.CompositeLit)
if !ok {
logFatal(nil, "Template functions %q variable is not a %T", variableName, &ast.CompositeLit{})
}
templateFunctions := make(map[string]string, len(compositeLiteral.Elts))
for _, el := range compositeLiteral.Elts {
kv := el.(*ast.KeyValueExpr)
key := kv.Key.(*ast.BasicLit)
value := kv.Value.(*ast.Ident)
funcName, err := strconv.Unquote(key.Value)
if err != nil {
logFatal(nil, "Failed to unquote template function name %q", key.Value)
}
templateFunctions[funcName] = value.Name
}
docsList := make([][]string, 0, len(templateFunctions))
for _, templateFuncName := range collections.SortedKeys(templateFunctions) {
goFuncName := templateFunctions[templateFuncName]
var funcDecl *ast.FuncDecl
ast.Inspect(astFile, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.FuncDecl:
if v.Name.Name == goFuncName {
funcDecl = v
return false
}
}
return true
})
if funcDecl == nil {
logFatal(nil, "Function %q is not defined in the file", goFuncName)
}
if funcDecl.Doc == nil {
logFatal(nil, "Function %q is missing documentation", goFuncName)
}
docLines := make([]string, 0, len(funcDecl.Doc.List))
for _, comment := range funcDecl.Doc.List {
text := strings.TrimPrefix(comment.Text, "//")
text = strings.TrimSpace(text)
text = strings.ReplaceAll(text, goFuncName, "'"+templateFuncName+"'")
docLines = append(docLines, text)
}
docsList = append(docsList, docLines)
}
return docsList
}
func logFatal(err error, msg string, a ...interface{}) {
var attrs []slog.Attr
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
}
logging.Logger().LogAttrs(context.Background(), slog.LevelError, fmt.Sprintf(msg, a...), attrs...)
}
package collections
import (
"slices"
"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
)
// SortedKeys returns a sorted slice of keys of the input map.
// The keys must meet [constraints.Ordered] type constraint.
func SortedKeys[M ~map[K]V, K constraints.Ordered, V any](m M) []K {
keys := maps.Keys(m)
slices.Sort(keys)
return keys
}
package collections
import "github.com/nobl9/govy/internal/stringconvert"
// ToStringSlice converts a slice of T to a slice of strings.
func ToStringSlice[T any](s []T) []string {
return mapSlice(s, func(v T) string { return stringconvert.Format(v) })
}
// mapSlice applies a mapping function f to each element of the slice (type T)
// and returns a new slice with the results mapped to type N.
func mapSlice[T, N any](s []T, f func(T) N) []N {
result := make([]N, 0, len(s))
for _, v := range s {
result = append(result, f(v))
}
return result
}
package internal
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
)
// JoinErrors joins multiple errors into a single pretty-formatted string.
// JoinErrors assumes the errors are not nil, if this presumption is broken the formatting might not be correct.
func JoinErrors[T error](b *strings.Builder, errs []T, indent string) {
for i, err := range errs {
if error(err) == nil {
continue
}
buildErrorMessage(b, err.Error(), indent)
if i < len(errs)-1 {
b.WriteString("\n")
}
}
}
const listPoint = "- "
func buildErrorMessage(b *strings.Builder, errMsg, indent string) {
b.WriteString(indent)
if !strings.HasPrefix(errMsg, listPoint) {
b.WriteString(listPoint)
}
// Indent the whole error message.
errMsg = strings.ReplaceAll(errMsg, "\n", "\n"+indent)
b.WriteString(errMsg)
}
var newLineReplacer = strings.NewReplacer("\n", "\\n", "\r", "\\r")
// PropertyValueString returns the string representation of the given value.
// Structs, interfaces, maps and slices are converted to compacted JSON strings (see struct exceptions below).
// It tries to improve readability by:
// - limiting the string to 100 characters
// - removing leading and trailing whitespaces
// - escaping newlines
//
// If value is a struct implementing [fmt.Stringer] [fmt.Stringer.String] method will be used only if:
// - the struct does not contain any JSON tags
// - the struct is not empty or it is empty but does not have any fields
//
// If a value is a struct of type [time.Time] it will be formatted using [time.RFC3339] layout.
func PropertyValueString(v interface{}) string {
if v == nil {
return ""
}
rv := reflect.ValueOf(v)
ft := reflect.Indirect(rv)
var s string
switch ft.Kind() {
case reflect.Interface, reflect.Map, reflect.Slice:
if rv.IsZero() {
break
}
raw, _ := json.Marshal(v)
s = string(raw)
case reflect.Struct:
// If the struct is empty and it has.
if rv.IsZero() && rv.NumField() != 0 {
break
}
if timeDate, ok := v.(time.Time); ok {
s = timeDate.Format(time.RFC3339)
break
}
if stringer, ok := v.(fmt.Stringer); ok && !hasJSONTags(v, rv.Kind() == reflect.Pointer) {
s = stringer.String()
break
}
raw, _ := json.Marshal(v)
s = string(raw)
case reflect.Ptr:
if rv.IsNil() {
return ""
}
deref := rv.Elem().Interface()
return PropertyValueString(deref)
case reflect.Func:
return "func"
case reflect.Invalid:
return ""
default:
s = fmt.Sprint(ft.Interface())
}
s = limitString(s, 100)
s = strings.TrimSpace(s)
s = newLineReplacer.Replace(s)
return s
}
func hasJSONTags(v interface{}, isPointer bool) bool {
t := reflect.TypeOf(v)
if isPointer {
t = t.Elem()
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if _, hasTag := field.Tag.Lookup("json"); hasTag {
return true
}
}
return false
}
func limitString(s string, limit int) string {
if len(s) > limit {
return s[:limit] + "..."
}
return s
}
package internal
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
)
const RequiredErrorMessage = "property is required but was empty"
const RequiredErrorCodeString = "required"
// IsEmptyFunc verifies if the value is zero value of its type.
func IsEmpty(v interface{}) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
return rv.Kind() == 0 || rv.IsZero()
}
var (
moduleRoot string
once sync.Once
)
// FindModuleRoot finds the root of the current module.
// It does so by looking for a go.mod file in the current working directory.
func FindModuleRoot() string {
once.Do(func() {
dir, err := os.Getwd()
if err != nil {
panic(err)
}
dir = filepath.Clean(dir)
for {
if fi, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil && !fi.IsDir() {
moduleRoot = dir
return
}
d := filepath.Dir(dir)
if d == dir {
break
}
dir = d
}
})
return moduleRoot
}
// PrettyStringListBuilder writes a list of arbitrary values to the provided [strings.Builder].
// It produces a human-readable comma-separated list.
// Example:
//
// PrettyStringListBuilder(b, []string{"foo", "bar"}, "") -> "foo, bar"
// PrettyStringListBuilder(b, []string{"foo", "bar"}, "'") -> "'foo', 'bar'"
func PrettyStringListBuilder[T any](b *strings.Builder, values []T, surroundingStr string) {
b.Grow(len(values))
for i := range values {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(surroundingStr)
fmt.Fprint(b, values[i])
b.WriteString(surroundingStr)
}
}
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"runtime"
"sync/atomic"
)
const defaultLogLevel = slog.LevelError
var (
logger atomic.Pointer[slog.Logger]
logLevel *slog.LevelVar
)
func Logger() *slog.Logger {
return logger.Load()
}
func SetLogLevel(level slog.Level) {
logLevel.Set(level)
}
func init() {
logLevel = new(slog.LevelVar)
if logLevelStr := os.Getenv("GOVY_LOG_LEVEL"); logLevelStr != "" {
level := new(slog.Level)
if err := level.UnmarshalText([]byte(logLevelStr)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %q: %v, defaulting to %s\n", logLevelStr, err, logLevel)
}
logLevel.Set(*level)
}
if logLevel.Level() == 0 {
logLevel.Set(defaultLogLevel)
}
jsonHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
// We're using our own source handler.
AddSource: false,
Level: logLevel,
})
// Source handler should always be the first in the chain
// in order to keep the number of frames it has to skip consistent.
handler := sourceHandler{Handler: jsonHandler}
defaultLogger := slog.New(contextHandler{Handler: handler})
logger.Store(defaultLogger)
}
type logContextAttrKey struct{}
// contextHandler is a [slog.Handler] that adds contextual attributes
// to the [slog.Record] before calling the underlying handler.
type contextHandler struct{ slog.Handler }
// Handle adds contextual attributes to the Record before calling the underlying handler.
func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
if attrs, ok := ctx.Value(logContextAttrKey{}).([]slog.Attr); ok {
for _, v := range attrs {
r.AddAttrs(v)
}
}
return h.Handler.Handle(ctx, r)
}
// sourceHandler is a [slog.Handler] that adds [slog.Source] information to the [slog.Record].
type sourceHandler struct{ slog.Handler }
// Handle adds [slog.Source] information to the [slog.Record]
// before calling the underlying handler.
func (h sourceHandler) Handle(ctx context.Context, r slog.Record) error {
f, ok := runtime.CallersFrames([]uintptr{r.PC}).Next()
if !ok {
r.AddAttrs(slog.Attr{
Key: slog.SourceKey,
Value: slog.AnyValue(&slog.Source{
Function: f.Function,
File: f.File,
Line: f.Line,
}),
})
}
return h.Handler.Handle(ctx, r)
}
package messagetemplates
import (
"fmt"
"sync"
"text/template"
)
// Get returns a message template by its key.
// If the template is not found, it panics.
// The first time a template is requested, it is parsed and stored in the cache.
// Custom functions and dependencies are added to the template automatically.
func Get(key templateKey) *template.Template {
if tpl := messageTemplatesCache.Lookup(key); tpl != nil {
return tpl
}
text, ok := rawMessageTemplates[key]
if !ok {
panic(fmt.Sprintf("message template %q was not found", key))
}
text += commonTemplateSuffix
tpl := newTemplate(key, text)
messageTemplatesCache.Register(key, tpl)
return tpl
}
// messageTemplatesCache is a cache for message templates
// which can be modified and accessed concurrently.
var messageTemplatesCache = newMessageTemplatesMap()
type messageTemplatesMap struct {
tmpl map[templateKey]*template.Template
mu sync.RWMutex
}
func (p *messageTemplatesMap) Lookup(key templateKey) *template.Template {
p.mu.RLock()
defer p.mu.RUnlock()
return p.tmpl[key]
}
func (p *messageTemplatesMap) Register(key templateKey, tpl *template.Template) {
p.mu.Lock()
p.tmpl[key] = tpl
p.mu.Unlock()
}
func newTemplate(key templateKey, text string) *template.Template {
tpl := template.New(key.String())
tpl = AddFunctions(tpl)
tpl = template.Must(tpl.Parse(text))
if deps, ok := templateDependencies[key]; ok {
addTemplatesToTemplateTree(tpl, deps...)
}
return tpl
}
// addTemplatesToTemplateTree adds message templates to a provided message template parse tree.
// This way they are available for use in the template.
// Example:
//
// {{ template "TemplateName" . }}
func addTemplatesToTemplateTree(tpl *template.Template, keys ...templateKey) {
for _, key := range keys {
text, ok := rawMessageTemplates[key]
if !ok {
panic(fmt.Sprintf("dependency template %q was not found", key))
}
depTpl := newTemplate(key, text)
if depTpl.Name() == "" {
panic(fmt.Sprintf("dependency template %q has no name", key))
}
if _, err := tpl.AddParseTree(depTpl.Name(), depTpl.Tree); err != nil {
panic(fmt.Sprintf("failed to add message template %q as a dependency for %q: %v",
depTpl.Name(), tpl.Name(), err))
}
}
}
func newMessageTemplatesMap() messageTemplatesMap {
return messageTemplatesMap{
tmpl: make(map[templateKey]*template.Template),
mu: sync.RWMutex{},
}
}
package messagetemplates
import (
"reflect"
"strings"
"text/template"
"github.com/nobl9/govy/internal"
)
// AddFunctions adds a set of custom functions to the provided template.
// These functions are used by builtin templates.
func AddFunctions(tpl *template.Template) *template.Template {
return tpl.Funcs(templateFunctions)
}
var templateFunctions = template.FuncMap{
"formatExamples": formatExamplesTplFunc,
"joinSlice": joinSliceTplFunc,
}
// formatExamplesTplFunc formats a list of strings which are example valid values
// as a single string representation.
// Example: `{{ formatExamples ["foo", "bar"] }}` -> "(e.g. 'foo', 'bar')"
func formatExamplesTplFunc(examples []string) string {
if len(examples) == 0 {
return ""
}
b := strings.Builder{}
b.WriteString("(e.g. ")
internal.PrettyStringListBuilder(&b, examples, "'")
b.WriteString(")")
return b.String()
}
// joinSliceTplFunc joins a list of values into a comma separated list of strings.
// Its second argument determines the surrounding string for each value.
// Example: `{{ joinSlice ["foo", "bar"] "'" }}` -> "'foo', 'bar'"
func joinSliceTplFunc(input any, surroundingStr string) string {
rv := reflect.ValueOf(input)
if rv.Kind() != reflect.Slice {
panic("first argument must be a slice")
}
if rv.Len() == 0 {
return ""
}
values := make([]any, 0, rv.Len())
for i := 0; i < rv.Len(); i++ {
values = append(values, rv.Index(i).Interface())
}
b := strings.Builder{}
internal.PrettyStringListBuilder(&b, values, surroundingStr)
return b.String()
}
// Code generated by "stringer -type=templateKey"; DO NOT EDIT.
package messagetemplates
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[LengthTemplate-1]
_ = x[MinLengthTemplate-2]
_ = x[MaxLengthTemplate-3]
_ = x[EQTemplate-4]
_ = x[NEQTemplate-5]
_ = x[GTTemplate-6]
_ = x[GTETemplate-7]
_ = x[LTTemplate-8]
_ = x[LTETemplate-9]
_ = x[EqualPropertiesTemplate-10]
_ = x[DurationPrecisionTemplate-11]
_ = x[ForbiddenTemplate-12]
_ = x[OneOfTemplate-13]
_ = x[OneOfPropertiesTemplate-14]
_ = x[MutuallyExclusiveTemplate-15]
_ = x[RequiredTemplate-16]
_ = x[StringNonEmptyTemplate-17]
_ = x[StringMatchRegexpTemplate-18]
_ = x[StringDenyRegexpTemplate-19]
_ = x[StringEmailTemplate-20]
_ = x[StringMACTemplate-21]
_ = x[StringIPTemplate-22]
_ = x[StringIPv4Template-23]
_ = x[StringIPv6Template-24]
_ = x[StringCIDRTemplate-25]
_ = x[StringCIDRv4Template-26]
_ = x[StringCIDRv6Template-27]
_ = x[StringJSONTemplate-28]
_ = x[StringContainsTemplate-29]
_ = x[StringExcludesTemplate-30]
_ = x[StringStartsWithTemplate-31]
_ = x[StringEndsWithTemplate-32]
_ = x[StringTitleTemplate-33]
_ = x[StringGitRefTemplate-34]
_ = x[StringFileSystemPathTemplate-35]
_ = x[StringFilePathTemplate-36]
_ = x[StringDirPathTemplate-37]
_ = x[StringMatchFileSystemPathTemplate-38]
_ = x[StringRegexpTemplate-39]
_ = x[StringCrontabTemplate-40]
_ = x[StringDateTimeTemplate-41]
_ = x[StringTimeZoneTemplate-42]
_ = x[StringKubernetesQualifiedNameTemplate-43]
_ = x[URLTemplate-44]
_ = x[SliceUniqueTemplate-45]
}
const _templateKey_name = "LengthTemplateMinLengthTemplateMaxLengthTemplateEQTemplateNEQTemplateGTTemplateGTETemplateLTTemplateLTETemplateEqualPropertiesTemplateDurationPrecisionTemplateForbiddenTemplateOneOfTemplateOneOfPropertiesTemplateMutuallyExclusiveTemplateRequiredTemplateStringNonEmptyTemplateStringMatchRegexpTemplateStringDenyRegexpTemplateStringEmailTemplateStringMACTemplateStringIPTemplateStringIPv4TemplateStringIPv6TemplateStringCIDRTemplateStringCIDRv4TemplateStringCIDRv6TemplateStringJSONTemplateStringContainsTemplateStringExcludesTemplateStringStartsWithTemplateStringEndsWithTemplateStringTitleTemplateStringGitRefTemplateStringFileSystemPathTemplateStringFilePathTemplateStringDirPathTemplateStringMatchFileSystemPathTemplateStringRegexpTemplateStringCrontabTemplateStringDateTimeTemplateStringTimeZoneTemplateStringKubernetesQualifiedNameTemplateURLTemplateSliceUniqueTemplate"
var _templateKey_index = [...]uint16{0, 14, 31, 48, 58, 69, 79, 90, 100, 111, 134, 159, 176, 189, 212, 237, 253, 275, 300, 324, 343, 360, 376, 394, 412, 430, 450, 470, 488, 510, 532, 556, 578, 597, 617, 645, 667, 688, 721, 741, 762, 784, 806, 843, 854, 873}
func (i templateKey) String() string {
i -= 1
if i < 0 || i >= templateKey(len(_templateKey_index)-1) {
return "templateKey(" + strconv.FormatInt(int64(i+1), 10) + ")"
}
return _templateKey_name[_templateKey_index[i]:_templateKey_index[i+1]]
}
package nameinfer
import "runtime"
// Frame returns the file and line number of the caller.
// It's intended to be used in the context of [govy.For] and similar functions.
func Frame(skipFrames int) (file string, line int) {
pc := make([]uintptr, 15)
n := runtime.Callers(skipFrames, pc)
frames := runtime.CallersFrames(pc[:n])
frame, _ := frames.Next()
return frame.File, frame.Line
}
package nameinfer
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"slices"
"golang.org/x/tools/go/packages"
"github.com/nobl9/govy/internal/logging"
"github.com/nobl9/govy/pkg/govyconfig"
)
var FunctionsWithGetter = []string{
"For",
"ForPointer",
"Transform",
"ForSlice",
"ForMap",
}
func InferName(file string, line int) string {
parseModuleASTOnce()
pkg, astFile := modAST.FindFile(file)
if astFile == nil {
return ""
}
return InferNameFromFile(modAST.FileSet, pkg, astFile, line)
}
func InferNameFromFile(fileSet *token.FileSet, pkg *packages.Package, f *ast.File, line int) string {
var (
getterNode ast.Node
previousNodeIsFuncWithGetter bool
)
importName := GetGovyImportName(f)
ast.Inspect(f, func(n ast.Node) bool {
if n == nil {
return false
}
nodeLine := fileSet.Position(n.Pos()).Line
if nodeLine > line {
return false
}
if nodeLine != line {
return true
}
// What follows must be the getter function.
if previousNodeIsFuncWithGetter {
getterNode = n
return false
}
switch v := n.(type) {
case *ast.SelectorExpr:
if se, isSelectorExpr := n.(*ast.SelectorExpr); isSelectorExpr {
exprIdent, ok := se.X.(*ast.Ident)
// FIXME: It's not safe to assume package name like that.
if ok && exprIdent.Name == importName && slices.Contains(FunctionsWithGetter, se.Sel.Name) {
previousNodeIsFuncWithGetter = true
return false
}
}
case *ast.Ident:
if slices.Contains(FunctionsWithGetter, v.Name) {
previousNodeIsFuncWithGetter = true
return false
}
}
return true
})
finder := nameFinder{pkg: pkg}
return finder.FindName(getterNode, nil)
}
func GetGovyImportName(f *ast.File) string {
importName := "govy"
for _, imp := range f.Imports {
if imp.Path.Value == `"github.com/nobl9/govy/pkg/govy"` && imp.Name != nil {
importName = imp.Name.Name
break
}
}
return importName
}
type nameFinder struct {
pkg *packages.Package
}
func (n nameFinder) FindName(a any, structType *types.Struct) string {
switch v := a.(type) {
case *ast.SelectorExpr:
name, _ := n.findNameInSelectorExpr(v, structType)
return name
case *ast.Ident:
return n.findNameInIdent(v, structType)
case *ast.AssignStmt:
return n.findNameInAssignStmt(v, structType)
case *ast.FuncLit:
return n.findNameInFuncLit(v)
case *ast.ReturnStmt:
return n.findNameInReturnStmt(v, structType)
case *ast.IfStmt:
return n.findNameInIfStmt(v, structType)
case *ast.BlockStmt:
return n.findNameInBlockStmt(v, structType)
default:
logging.Logger().Debug(fmt.Sprintf("unexpected type: %T", v))
}
return ""
}
func (n nameFinder) findNameInBlockStmt(blockStmt *ast.BlockStmt, structType *types.Struct) string {
if blockStmt == nil {
logging.Logger().Debug("*ast.BlockStmt is nil, failed to locate the getter function parent")
return ""
}
for _, stmt := range blockStmt.List {
if name := n.FindName(stmt, structType); name != "" {
return name
}
}
return ""
}
func (n nameFinder) findNameInIfStmt(ifStmt *ast.IfStmt, structType *types.Struct) string {
if ifStmt == nil {
logging.Logger().Debug("*ast.IfStmt is nil, failed to locate the getter function parent")
return ""
}
return n.FindName(ifStmt.Body, structType)
}
// findNameInFuncLit returns the name of the property that the getter function is supposed to return.
// It attempts to find the return statement which lets us infer the name,
// until it succeeds or there are no more return statements to inspect.
func (n nameFinder) findNameInFuncLit(fl *ast.FuncLit) string {
if fl == nil {
logging.Logger().Debug("*ast.FuncLit is nil, failed to locate the getter function parent")
return ""
}
paramsList := fl.Type.Params.List
if len(paramsList) != 1 {
logging.Logger().Debug("*ast.FuncLit must have exactly one parameter")
return ""
}
paramIdent, ok := paramsList[0].Type.(*ast.Ident)
if !ok {
logging.Logger().Debug("parameter must be an identifier")
return ""
}
object := n.pkg.TypesInfo.ObjectOf(paramIdent)
if object == nil {
logging.Logger().Debug("failed to locate the object for the parameter identifier")
return ""
}
var structType *types.Struct
switch ot := object.Type().(type) {
case *types.Named:
switch ut := ot.Underlying().(type) {
case *types.Struct:
structType = ut
default:
logging.Logger().Debug(fmt.Sprintf("unexpected type: %T", ut))
return ""
}
default:
logging.Logger().Debug(fmt.Sprintf("unexpected type: %T", ot))
return ""
}
for _, stmt := range fl.Body.List {
if name := n.FindName(stmt, structType); name != "" {
return name
}
}
return ""
}
func (n nameFinder) findNameInReturnStmt(returnStmt *ast.ReturnStmt, structType *types.Struct) string {
if returnStmt == nil {
logging.Logger().Debug("no return statement found in getter function")
return ""
}
if len(returnStmt.Results) != 1 {
logging.Logger().Debug("return statement must have exactly one result")
return ""
}
return n.FindName(returnStmt.Results[0], structType)
}
func (n nameFinder) findNameInIdent(ident *ast.Ident, structType *types.Struct) string {
if ident.Obj == nil {
logging.Logger().Debug("identifier object is nil")
return ""
}
return n.FindName(ident.Obj.Decl, structType)
}
func (n nameFinder) findNameInAssignStmt(assignment *ast.AssignStmt, structType *types.Struct) string {
if len(assignment.Rhs) != 1 {
logging.Logger().Debug("assignment statement must have exactly one right-hand side")
return ""
}
return n.FindName(assignment.Rhs[0], structType)
}
func (n nameFinder) findNameInSelectorExpr(
se *ast.SelectorExpr,
structType *types.Struct,
) (string, *types.Struct) {
var name string
switch v := se.X.(type) {
case *ast.Ident:
break
case *ast.SelectorExpr:
name, structType = n.findNameInSelectorExpr(v, structType)
default:
logging.Logger().Debug(fmt.Sprintf("unexpected type: %T", v))
return "", nil
}
if structType == nil {
return "", nil
}
for i := range structType.NumFields() {
field := structType.Field(i)
fieldName := field.Name()
if fieldName != se.Sel.Name {
continue
}
tagValue := structType.Tag(i)
if childStructType, isStruct := n.findStructTypeInStructField(field); isStruct {
structType = childStructType
}
fieldName = govyconfig.GetNameInferFunc()(fieldName, tagValue)
if name == "" {
return fieldName, structType
}
return name + "." + fieldName, structType
}
logging.Logger().Debug(fmt.Sprintf("field matching '%s' name not found in struct type", se.Sel.Name))
return "", nil
}
// findStructTypeInStructField returns the underlying [*types.Struct] of [*ast.Field] if it's a struct.
func (n nameFinder) findStructTypeInStructField(field *types.Var) (*types.Struct, bool) {
switch ut := field.Type().Underlying().(type) {
case *types.Struct:
return ut, true
default:
return nil, false
}
}
package nameinfer
import (
"go/ast"
"go/token"
"log"
"sync"
"golang.org/x/tools/go/packages"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/pkg/govyconfig"
)
const packagesMode = packages.NeedName |
packages.NeedFiles |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo |
packages.NeedImports
func NewModuleAST(root string) ModuleAST {
fileSet := token.NewFileSet()
cfg := &packages.Config{
Fset: fileSet,
Mode: packagesMode,
Dir: root,
Tests: govyconfig.GetNameInferIncludeTestFiles(),
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
log.Fatal(err)
}
pkgMap := make(map[string]*packages.Package, len(pkgs))
for _, pkg := range pkgs {
pkgMap[pkg.PkgPath] = pkg
}
return ModuleAST{
FileSet: fileSet,
Packages: pkgMap,
}
}
type ModuleAST struct {
FileSet *token.FileSet
Packages map[string]*packages.Package
}
func (a ModuleAST) FindFile(file string) (*packages.Package, *ast.File) {
for _, pkg := range a.Packages {
for i, filePath := range pkg.GoFiles {
if filePath == file {
return pkg, pkg.Syntax[i]
}
}
}
return nil, nil
}
var (
modAST ModuleAST
parseASTOnce sync.Once
)
func parseModuleASTOnce() {
parseASTOnce.Do(func() { modAST = NewModuleAST(internal.FindModuleRoot()) })
}
package stringconvert
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"github.com/nobl9/govy/internal/logging"
)
// Format converts any value to a pretty, human-readable string representation.
func Format(v any) string {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
return Format(rv.Elem())
}
switch rv.Kind() {
case reflect.Struct, reflect.Map:
data, err := json.Marshal(v)
if err != nil {
logging.Logger().Error("unexpected error", slog.String("err", err.Error()))
}
return string(data)
case reflect.Slice, reflect.Array:
result := "["
for i := 0; i < rv.Len(); i++ {
if i > 0 {
result += ", "
}
result += Format(rv.Index(i).Interface())
}
return result + "]"
default:
return fmt.Sprint(v)
}
}
package typeinfo
import (
"fmt"
"reflect"
)
// TypeInfo stores the Go type information.
type TypeInfo struct {
Name string
Kind string
Package string
}
// Get returns the information for the type T.
// It returns the type name without package path or name.
// It strips the pointer '*' from the type name.
// Package is only available if the type is not a built-in type.
//
// It has a special treatment for slices of type definitions.
// Instead of having:
//
// TypeInfo{Name: "[]mypkg.Bar"}
//
// It will produce:
//
// TypeInfo{Name: "[]Bar", Package: ".../mypkg"}.
func Get[T any]() TypeInfo {
typ := reflect.TypeOf(*new(T))
if typ == nil {
return TypeInfo{}
}
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
result := TypeInfo{
Kind: getKindString(typ),
}
if typ.PkgPath() == "" && typ.Kind() == reflect.Slice {
result.Name = "[]"
typ = typ.Elem()
}
switch {
case typ.PkgPath() == "":
result.Name += typ.String()
default:
result.Name += typ.Name()
result.Package = typ.PkgPath()
}
return result
}
func getKindString(typ reflect.Type) string {
switch typ.Kind() {
case reflect.Map:
return fmt.Sprintf("map[%s]%s", getKindString(typ.Key()), getKindString(typ.Elem()))
case reflect.Slice:
return fmt.Sprintf("[]%s", getKindString(typ.Elem()))
default:
return typ.Kind().String()
}
}
package govy
import "strings"
// ErrorCode is a unique string that represents a specific [RuleError].
// It can be used to precisely identify the error without inspecting its message.
type ErrorCode string
const (
ErrorCodeTransform ErrorCode = "transform"
)
// Add extends the error code with a new error code.
// Codes are prepended, the last code in chain is always the first one set.
// Example:
//
// ErrorCode("first").Add("another").Add("last") --> ErrorCode("last:another:first")
func (e ErrorCode) Add(code ErrorCode) ErrorCode {
switch {
case e == "":
return code
case code == "":
return e
default:
return code + ErrorCode(ErrorCodeSeparator) + e
}
}
// Has reports whether given error code is in the examined error code's chain.
// Example:
//
// ErrorCode("foo:bar").Has("foo") --> true
// ErrorCode("foo:bar").Has("bar") --> true
// ErrorCode("foo:bar").Has("baz") --> false
func (e ErrorCode) Has(code ErrorCode) bool {
if e == "" || code == "" {
return false
}
if e == code {
return true
}
i := 0
for {
if i >= len(e) {
return false
}
if e[i:] == code {
return true
}
next := strings.Index(string(e[i:]), ErrorCodeSeparator)
switch {
case next == -1:
return false
case e[i:i+next] == code:
return true
}
i += next + 1
}
}
package govy
import (
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/logging"
)
const (
ErrorCodeSeparator = string(errorCodeSeparatorRune)
errorCodeSeparatorRune = ':'
propertyNameSeparator = "."
hiddenValue = "[hidden]"
)
func NewValidatorError(errs PropertyErrors) *ValidatorError {
return &ValidatorError{Errors: errs}
}
// ValidatorError is the top-level error type for validation errors.
// It aggregates the property errors of [Validator].
type ValidatorError struct {
Errors PropertyErrors `json:"errors"`
// Name is an optional name of the [Validator].
Name string `json:"name,omitempty"`
// SliceIndex is set if the error was created by [Validator.ValidateSlice].
// It contains a 0-based index.
SliceIndex *int `json:"sliceIndex,omitempty"`
}
// WithName sets the [ValidatorError.Name] field.
func (e *ValidatorError) WithName(name string) *ValidatorError {
e.Name = name
return e
}
// Error implements the error interface.
func (e *ValidatorError) Error() string {
b := strings.Builder{}
b.WriteString("Validation")
if e.Name != "" {
b.WriteString(" for ")
b.WriteString(e.Name)
}
if e.SliceIndex != nil {
b.WriteString(" at index ")
b.WriteString(strconv.Itoa(*e.SliceIndex))
}
b.WriteString(" has failed for the following properties:\n")
internal.JoinErrors(&b, e.Errors, strings.Repeat(" ", 2))
return b.String()
}
// ValidatorErrors is a slice of [ValidatorError].
type ValidatorErrors []*ValidatorError
// Error implements the error interface.
func (e ValidatorErrors) Error() string {
b := strings.Builder{}
for i, vErr := range e {
b.WriteString(vErr.Error())
if i < len(e)-1 {
b.WriteString("\n")
}
}
return b.String()
}
// PropertyErrors is a slice of [PropertyError].
type PropertyErrors []*PropertyError
// Error implements the error interface.
func (e PropertyErrors) Error() string {
b := strings.Builder{}
internal.JoinErrors(&b, e, "")
return b.String()
}
// HideValue hides the property value from each of the [PropertyError].
func (e PropertyErrors) HideValue() PropertyErrors {
for _, err := range e {
_ = err.HideValue()
}
return e
}
// sort should be always called after aggregate.
func (e PropertyErrors) sort() PropertyErrors {
if len(e) == 0 {
return e
}
sort.Slice(e, func(i, j int) bool {
e1, e2 := e[i], e[j]
if e1.PropertyName != e2.PropertyName {
return e1.PropertyName < e2.PropertyName
}
if e1.PropertyValue != e2.PropertyValue {
return e1.PropertyValue < e2.PropertyValue
}
if e1.IsKeyError != e2.IsKeyError {
return e1.IsKeyError
}
return e1.IsSliceElementError
})
return e
}
// aggregate merges [PropertyError] with according to the [PropertyError.Equal] comparison.
func (e PropertyErrors) aggregate() PropertyErrors {
if len(e) == 0 {
return nil
}
agg := make(PropertyErrors, 0, len(e))
outer:
for _, e1 := range e {
for _, e2 := range agg {
if e1.Equal(e2) {
e2.Errors = append(e2.Errors, e1.Errors...)
continue outer
}
}
agg = append(agg, e1)
}
return agg
}
func NewPropertyError(propertyName string, propertyValue any, errs ...error) *PropertyError {
return &PropertyError{
PropertyName: propertyName,
PropertyValue: internal.PropertyValueString(propertyValue),
Errors: unpackRuleErrors(errs, make([]*RuleError, 0, len(errs))),
}
}
// PropertyError is the error returned by [PropertyRules.Validate].
// It contains property level details along with all the [RuleError] encountered for that property.
type PropertyError struct {
PropertyName string `json:"propertyName"`
PropertyValue string `json:"propertyValue,omitempty"`
// IsKeyError is set to true if the error was created through map key validation.
// PropertyValue in this scenario will be the key value, equal to the last element of PropertyName path.
IsKeyError bool `json:"isKeyError,omitempty"`
// IsSliceElementError is set to true if the error was created through slice element validation.
IsSliceElementError bool `json:"isSliceElementError,omitempty"`
Errors []*RuleError `json:"errors"`
}
// Error implements the error interface.
func (e *PropertyError) Error() string {
b := new(strings.Builder)
indent := ""
if e.PropertyName != "" {
fmt.Fprintf(b, "'%s'", e.PropertyName)
if e.PropertyValue != "" {
if e.IsKeyError {
fmt.Fprintf(b, " with key '%s'", e.PropertyValue)
} else {
fmt.Fprintf(b, " with value '%s'", e.PropertyValue)
}
}
b.WriteString(":\n")
indent = strings.Repeat(" ", 2)
}
internal.JoinErrors(b, e.Errors, indent)
return b.String()
}
// Equal checks if two [PropertyError] are equal.
func (e *PropertyError) Equal(cmp *PropertyError) bool {
return e.PropertyName == cmp.PropertyName &&
e.PropertyValue == cmp.PropertyValue &&
e.IsKeyError == cmp.IsKeyError &&
e.IsSliceElementError == cmp.IsSliceElementError
}
// PrependParentPropertyName prepends a given name to the [PropertyError.PropertyName].
func (e *PropertyError) PrependParentPropertyName(name string) *PropertyError {
sep := propertyNameSeparator
if e.IsSliceElementError && strings.HasPrefix(e.PropertyName, "[") {
sep = ""
}
e.PropertyName = concatStrings(name, e.PropertyName, sep)
return e
}
// HideValue hides the property value from each of the [PropertyError.Errors].
func (e *PropertyError) HideValue() *PropertyError {
sv := internal.PropertyValueString(e.PropertyValue)
e.PropertyValue = ""
for _, err := range e.Errors {
_ = err.HideValue(sv)
}
return e
}
// NewRuleError creates a new [RuleError] with the given message and optional error codes.
// Error codes are added according to the rules defined by [RuleError.AddCode].
func NewRuleError(message string, codes ...ErrorCode) *RuleError {
ruleError := &RuleError{Message: message}
for _, code := range codes {
ruleError = ruleError.AddCode(code)
}
return ruleError
}
// RuleError is the base error associated with a [Rule].
// It is returned by [Rule.Validate].
type RuleError struct {
Message string `json:"error"`
Code ErrorCode `json:"code,omitempty"`
Description string `json:"description,omitempty"`
}
// Error implements the error interface.
// It simply returns the underlying [RuleError.Message].
func (r *RuleError) Error() string {
return r.Message
}
// AddCode extends the [RuleError] with the given error code.
// See [ErrorCode.Add] for more details.
func (r *RuleError) AddCode(code ErrorCode) *RuleError {
r.Code = r.Code.Add(code)
return r
}
// HideValue replaces all occurrences of a string in the [RuleError.Message] with a '*' characters.
func (r *RuleError) HideValue(stringValue string) *RuleError {
r.Message = strings.ReplaceAll(r.Message, stringValue, hiddenValue)
return r
}
// RuleSetError is a container for transferring multiple errors reported by [RuleSet.Validate].
type RuleSetError []error
// Error implements the error interface.
func (r RuleSetError) Error() string {
b := new(strings.Builder)
internal.JoinErrors(b, r, "")
return b.String()
}
// NewRuleErrorTemplate creates a new [RuleErrorTemplate] with the given template variables.
// The variables can be of any type, most commonly it would be a struct or a map.
// These variables are then passed to [template.Template.Execute].
// Example:
//
// return govy.NewRuleErrorTemplate(map[string]string{
// "Name": "my-property",
// "MaxLength": 2,
// })
//
// For more details on Go templates see: https://pkg.go.dev/text/template.
func NewRuleErrorTemplate(vars TemplateVars) RuleErrorTemplate {
return RuleErrorTemplate{vars: vars}
}
// RuleErrorTemplate is a container for passing template variables under the guise of an error.
// It's not meant to be used directly as an error but rather
// unpacked by [Rule] in order to create a templated error message.
type RuleErrorTemplate struct {
vars TemplateVars
}
// Error implements the error interface.
// Since [RuleErrorTemplate] should not be used directly this function returns.
func (e RuleErrorTemplate) Error() string {
return fmt.Sprintf("%T should not be used directly", e)
}
// TemplateVars lists all the possible variables that can be used by builtin rules' message templates.
// Reuse the variable names to keep the consistency across all the rules.
type TemplateVars struct {
// Common variables which are available for all the rules.
PropertyValue any
Examples []string
Details string
// Builtin, widely used variables which are available only for selected rules.
// Error is the dynamic error returned by underlying functions evaluated by the rule,
// for instance an error returned by [net/url.Parse].
Error string
// ComparisonValue is the value defined most commonly during rule creation
// to which runtime values are compared.
ComparisonValue any
MinLength int
MaxLength int
// Custom variables either provided by the user or case specific,
// this can be anything, for instance map[string]any or a struct.
Custom any
}
// HasErrorCode checks if an error contains given [ErrorCode].
// It supports all govy errors.
func HasErrorCode(err error, code ErrorCode) bool {
switch v := err.(type) {
case *ValidatorError:
for _, e := range v.Errors {
if HasErrorCode(e, code) {
return true
}
}
return false
case ValidatorErrors:
for _, e := range v {
if HasErrorCode(e, code) {
return true
}
}
return false
case PropertyErrors:
for _, e := range v {
if HasErrorCode(e, code) {
return true
}
}
return false
case *PropertyError:
for _, e := range v.Errors {
if HasErrorCode(e, code) {
return true
}
}
case *RuleError:
return v.Code.Has(code)
case RuleSetError:
for _, e := range v {
if HasErrorCode(e, code) {
return true
}
}
}
return false
}
// unpackRuleErrors unpacks error messages recursively scanning [RuleSetError] if it is detected.
func unpackRuleErrors(errs []error, ruleErrors []*RuleError) []*RuleError {
for _, err := range errs {
switch v := err.(type) {
case RuleSetError:
ruleErrors = unpackRuleErrors(v, ruleErrors)
case *RuleError:
ruleErrors = append(ruleErrors, v)
default:
ruleErrors = append(ruleErrors, &RuleError{Message: v.Error()})
}
}
return ruleErrors
}
func concatStrings(pre, post, sep string) string {
if pre == "" {
return post
}
if post == "" {
return pre
}
return pre + sep + post
}
func logWrongErrorType(expected, actual error) {
logging.Logger().Error("unexpected error type",
slog.String("actual_type", fmt.Sprintf("%T", actual)),
slog.String("expected_type", fmt.Sprintf("%T", expected)))
}
package govy
import (
"fmt"
"github.com/nobl9/govy/internal/logging"
"github.com/nobl9/govy/internal/nameinfer"
"github.com/nobl9/govy/pkg/govyconfig"
)
func inferName() string {
return inferNameWithMode(govyconfig.GetNameInferMode())
}
func inferNameWithMode(mode govyconfig.NameInferMode) string {
switch mode {
case govyconfig.NameInferModeDisable:
return ""
case govyconfig.NameInferModeGenerate:
file, line := nameinfer.Frame(5)
return govyconfig.GetInferredName(file, line)
case govyconfig.NameInferModeRuntime:
file, line := nameinfer.Frame(5)
return nameinfer.InferName(file, line)
default:
logging.Logger().Error(fmt.Sprintf("unknown %T", mode))
return ""
}
}
package govy
import (
"text/template"
"github.com/nobl9/govy/internal/messagetemplates"
)
//go:generate go run ../../internal/cmd/docextractor/main.go
// AddTemplateFunctions adds a set of utility functions to the provided template.
// The following functions are made available for use in the templates:
//
// - 'formatExamples' formats a list of strings which are example valid values
// as a single string representation.
// Example: `{{ formatExamples ["foo", "bar"] }}` -> "(e.g. 'foo', 'bar')"
//
// - 'joinSlice' joins a list of values into a comma separated list of strings.
// Its second argument determines the surrounding string for each value.
// Example: `{{ joinSlice ["foo", "bar"] "'" }}` -> "'foo', 'bar'"
//
// Refer to the testable examples of [AddTemplateFunctions] for more details
// on each builtin function.
func AddTemplateFunctions(tpl *template.Template) *template.Template {
return messagetemplates.AddFunctions(tpl)
}
package govy
import (
"sort"
"strings"
"golang.org/x/exp/maps"
)
// ValidatorPlan is a validation plan for a single [Validator].
type ValidatorPlan struct {
Name string `json:"name,omitempty"`
Properties []PropertyPlan `json:"properties"`
}
// PropertyPlan is a validation plan for a single [PropertyRules].
type PropertyPlan struct {
// Path is a JSON path to the property.
Path string `json:"path"`
// TypeInfo contains the type information of the property.
TypeInfo TypeInfo `json:"typeInfo"`
// IsOptional indicates if the property was marked with [PropertyRules.OmitEmpty].
IsOptional bool `json:"isOptional,omitempty"`
// IsHidden indicates if the property was marked with [PropertyRules.HideValue].
IsHidden bool `json:"isHidden,omitempty"`
Examples []string `json:"examples,omitempty"`
Rules []RulePlan `json:"rules,omitempty"`
}
// TypeInfo contains the type information of a property.
type TypeInfo struct {
// Name is a Go type name.
// Example: "Pod", "string", "int", "bool", etc.
Name string `json:"name"`
// Kind is a Go type kind.
// Example: "string", "int", "bool", "struct", "slice", etc.
Kind string `json:"kind"`
// Package is the full package path of the type.
// It's empty for builtin types.
// Example: "github.com/nobl9/govy/pkg/govy", "time", etc.
Package string `json:"package,omitempty"`
}
// RulePlan is a validation plan for a single [Rule].
type RulePlan struct {
Description string `json:"description"`
Details string `json:"details,omitempty"`
ErrorCode ErrorCode `json:"errorCode,omitempty"`
// Conditions are all the predicates set through [PropertyRules.When] and [Validator.When]
// which had [WhenDescription] added to the [WhenOptions].
Conditions []string `json:"conditions,omitempty"`
Examples []string `json:"examples,omitempty"`
}
func (r RulePlan) isEmpty() bool {
return r.Description == "" && r.Details == "" && r.ErrorCode == "" && len(r.Conditions) == 0
}
// Plan creates a validation plan for the provided [Validator].
// Each property is represented by a [PropertyPlan] which aggregates its every [RulePlan].
// If a property does not have any rules, it won't be included in the result.
func Plan[S any](v Validator[S]) *ValidatorPlan {
all := make([]planBuilder, 0)
v.plan(planBuilder{path: "$", children: &all})
propertiesMap := make(map[string]PropertyPlan)
for _, p := range all {
entry, ok := propertiesMap[p.path]
if ok {
entry.Rules = append(entry.Rules, p.rulePlan)
propertiesMap[p.path] = entry
} else {
entry = PropertyPlan{
Path: p.path,
TypeInfo: p.propertyPlan.TypeInfo,
Examples: p.propertyPlan.Examples,
IsOptional: p.propertyPlan.IsOptional,
IsHidden: p.propertyPlan.IsHidden,
}
if !p.rulePlan.isEmpty() {
entry.Rules = append(entry.Rules, p.rulePlan)
}
propertiesMap[p.path] = entry
}
}
properties := maps.Values(propertiesMap)
sort.Slice(properties, func(i, j int) bool { return properties[i].Path < properties[j].Path })
return &ValidatorPlan{
Name: v.name,
Properties: properties,
}
}
// planner is an interface for types that can create a [PropertyPlan] or [RulePlan].
type planner interface {
plan(builder planBuilder)
}
// planBuilder is used to traverse the validation rules and build a slice of [PropertyPlan].
type planBuilder struct {
path string
rulePlan RulePlan
propertyPlan PropertyPlan
// children stores every rule for the current property.
// It's not safe for concurrent usage.
children *[]planBuilder
}
func (p planBuilder) appendPath(path string) planBuilder {
builder := planBuilder{
children: p.children,
rulePlan: p.rulePlan,
propertyPlan: p.propertyPlan,
}
switch {
case p.path == "" && path != "":
builder.path = path
case p.path != "" && path != "":
if strings.HasPrefix(path, "[") {
builder.path = p.path + path
} else {
builder.path = p.path + "." + path
}
default:
builder.path = p.path
}
return builder
}
func (p planBuilder) setExamples(examples ...string) planBuilder {
p.propertyPlan.Examples = examples
return p
}
package govy
import "fmt"
// WhenOptions defines optional parameters for the When conditions.
type WhenOptions struct {
description string
}
// WhenDescription sets the description for the When condition.
func WhenDescription(format string, a ...interface{}) WhenOptions {
return WhenOptions{description: fmt.Sprintf(format, a...)}
}
// Predicate defines a function that returns a boolean value.
type Predicate[T any] func(T) bool
type predicateContainer[T any] struct {
predicate Predicate[T]
description string
}
type predicateMatcher[T any] struct {
predicates []predicateContainer[T]
}
func (p predicateMatcher[T]) when(predicate Predicate[T], opts ...WhenOptions) predicateMatcher[T] {
container := predicateContainer[T]{predicate: predicate}
for _, opt := range opts {
if opt.description != "" {
container.description = opt.description
}
}
p.predicates = append(p.predicates, container)
return p
}
func (p predicateMatcher[T]) matchPredicates(st T) bool {
for _, predicate := range p.predicates {
if !predicate.predicate(st) {
return false
}
}
return true
}
package govy
import (
"bytes"
"fmt"
"strings"
"text/template"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/collections"
"github.com/nobl9/govy/internal/messagetemplates"
)
// NewRule creates a new [Rule] instance.
func NewRule[T any](validate func(v T) error) Rule[T] {
return Rule[T]{validate: validate}
}
// RuleToPointer converts an existing [Rule] to its pointer variant.
// It retains all the properties of the original [Rule],
// but modifies its type constraints to work with a pointer to the original type.
// If the validated value is nil, the validation will be skipped.
func RuleToPointer[T any](rule Rule[T]) Rule[*T] {
return Rule[*T]{
validate: func(v *T) error {
if v == nil {
return nil
}
return rule.validate(*v)
},
errorCode: rule.errorCode,
details: rule.details,
message: rule.message,
messageTemplate: rule.messageTemplate,
examples: rule.examples,
description: rule.description,
}
}
// Rule is the basic validation building block.
// It evaluates the provided validation function and enhances it
// with optional [ErrorCode] and arbitrary details.
type Rule[T any] struct {
validate func(v T) error
errorCode ErrorCode
details string
message string
messageTemplate *template.Template
examples []string
description string
}
// Validate runs validation function on the provided value.
// It can handle different types of errors returned by the function:
// - [*RuleError], which details and [ErrorCode] are optionally extended with the ones defined by [Rule].
// - [*PropertyError], for each of its errors their [ErrorCode] is extended with the one defined by [Rule].
// - [RuleErrorTemplate], if message template was set with [Rule.WithMessageTemplate] or
// [Rule.WithMessageTemplateString] then the [RuleError.Message] is constructed from the provided template
// using variables passed inside [RuleErrorTemplate.vars].
//
// By default, it will construct a new [*RuleError].
func (r Rule[T]) Validate(v T) error {
if err := r.validate(v); err != nil {
switch ev := err.(type) {
case *RuleError:
if len(r.message) > 0 {
ev.Message = createErrorMessage(r.message, r.details, r.examples)
}
ev.Description = r.description
return ev.AddCode(r.errorCode)
case *PropertyError:
for _, e := range ev.Errors {
_ = e.AddCode(r.errorCode)
}
return ev
case RuleErrorTemplate:
if r.message != "" {
break
}
if r.messageTemplate == nil {
panic(fmt.Sprintf("rule returned %T error but message template is not set", ev))
}
ev.vars.PropertyValue = v
ev.vars.Details = r.details
ev.vars.Examples = r.examples
var buf bytes.Buffer
if err = r.messageTemplate.Execute(&buf, ev.vars); err != nil {
panic(fmt.Sprintf("failed to execute message template: %s", err))
}
return &RuleError{
Message: buf.String(),
Code: r.errorCode,
Description: r.description,
}
}
msg := err.Error()
if len(r.message) > 0 {
msg = r.message
}
return &RuleError{
Message: createErrorMessage(msg, r.details, r.examples),
Code: r.errorCode,
Description: r.description,
}
}
return nil
}
// WithErrorCode sets the error code for the returned [RuleError].
func (r Rule[T]) WithErrorCode(code ErrorCode) Rule[T] {
r.errorCode = code
return r
}
// WithMessage overrides the returned [RuleError] error message.
func (r Rule[T]) WithMessage(format string, a ...any) Rule[T] {
r.messageTemplate = nil
if len(a) == 0 {
r.message = format
} else {
r.message = fmt.Sprintf(format, a...)
}
return r
}
// WithMessageTemplate overrides the returned [RuleError] error message using provided [template.Template].
func (r Rule[T]) WithMessageTemplate(tpl *template.Template) Rule[T] {
r.messageTemplate = messagetemplates.AddFunctions(tpl)
return r
}
// WithMessageTemplateString overrides the returned [RuleError] error message using provided template string.
// The string is parsed into [template.Template], it panics if any error is encountered during parsing.
func (r Rule[T]) WithMessageTemplateString(tplStr string) Rule[T] {
tpl := messagetemplates.AddFunctions(template.New(""))
tpl, err := tpl.Parse(tplStr)
if err != nil {
panic(fmt.Sprintf("failed to parse message template: %s", err))
}
return r.WithMessageTemplate(tpl)
}
// WithDetails adds details to the returned [RuleError] error message.
func (r Rule[T]) WithDetails(format string, a ...any) Rule[T] {
if len(a) == 0 {
r.details = format
} else {
r.details = fmt.Sprintf(format, a...)
}
return r
}
// WithExamples adds examples to the returned [RuleError].
// Each example is converted to a string.
func (r Rule[T]) WithExamples(examples ...T) Rule[T] {
r.examples = collections.ToStringSlice(examples)
return r
}
// WithDescription adds a custom description to the rule.
// It is used to enhance the [RulePlan], but otherwise does not appear in standard [RuleError.Error] output.
func (r Rule[T]) WithDescription(description string) Rule[T] {
r.description = description
return r
}
func (r Rule[T]) plan(builder planBuilder) {
builder.rulePlan = RulePlan{
ErrorCode: r.errorCode,
Details: r.details,
Description: r.description,
Conditions: builder.rulePlan.Conditions,
Examples: r.examples,
}
*builder.children = append(*builder.children, builder)
}
func createErrorMessage(message, details string, examples []string) string {
if message == "" {
return details
}
message += examplesToString(examples)
if details == "" {
return message
}
return message + "; " + details
}
func examplesToString(examples []string) string {
if len(examples) == 0 {
return ""
}
b := strings.Builder{}
b.WriteString(" (e.g. ")
internal.PrettyStringListBuilder(&b, examples, "'")
b.WriteString(")")
return b.String()
}
package govy
// NewRuleSet creates a new [RuleSet] instance.
func NewRuleSet[T any](rules ...Rule[T]) RuleSet[T] {
return RuleSet[T]{rules: rules}
}
// RuleSetToPointer converts an existing [RuleSet] to its pointer variant.
// It retains all the properties of the original [RuleSet],
// and modifies its type constraints to work with a pointer to the original type.
// It calls [RuleToPointer] for each of the underlying [Rule].
func RuleSetToPointer[T any](ruleSet RuleSet[T]) RuleSet[*T] {
rules := make([]Rule[*T], 0, len(ruleSet.rules))
for _, rule := range ruleSet.rules {
rules = append(rules, RuleToPointer(rule))
}
return RuleSet[*T]{
rules: rules,
}
}
// RuleSet allows defining a [Rule] which aggregates multiple sub-rules.
type RuleSet[T any] struct {
rules []Rule[T]
mode CascadeMode
}
// Validate works the same way as [Rule.Validate],
// except each aggregated rule is validated individually.
// The errors are aggregated and returned as a single [RuleSetError]
// which serves as a container for them.
func (r RuleSet[T]) Validate(v T) error {
var errs RuleSetError
for i := range r.rules {
err := r.rules[i].Validate(v)
if err == nil {
continue
}
switch ev := err.(type) {
case *RuleError, *PropertyError:
errs = append(errs, ev)
default:
errs = append(errs, &RuleError{
Message: ev.Error(),
})
}
if r.mode == CascadeModeStop {
break
}
}
if len(errs) > 0 {
return errs
}
return nil
}
// WithErrorCode sets the error code for each returned [RuleError].
func (r RuleSet[T]) WithErrorCode(code ErrorCode) RuleSet[T] {
for i := range r.rules {
r.rules[i].errorCode = r.rules[i].errorCode.Add(code)
}
return r
}
// Cascade sets the [CascadeMode] for the rule set,
// which controls the flow of evaluating the validation rules.
func (r RuleSet[T]) Cascade(mode CascadeMode) RuleSet[T] {
r.mode = mode
return r
}
func (r RuleSet[T]) plan(builder planBuilder) {
for _, rule := range r.rules {
rule.plan(builder)
}
}
package govy
import (
"errors"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/typeinfo"
)
// For creates a new [PropertyRules] instance for the property
// which value is extracted through [PropertyGetter] function.
func For[T, S any](getter PropertyGetter[T, S]) PropertyRules[T, S] {
return forConstructor(getter, inferName())
}
func forConstructor[T, S any](getter PropertyGetter[T, S], name string) PropertyRules[T, S] {
return PropertyRules[T, S]{
name: name,
getter: func(s S) (v T, err error) { return getter(s), nil },
}
}
// ForPointer accepts a getter function returning a pointer and wraps its call in order to
// safely extract the value under the pointer or return a zero value for a give type T.
// If required is set to true, the nil pointer value will result in an error and the
// validation will not proceed.
func ForPointer[T, S any](getter PropertyGetter[*T, S]) PropertyRules[T, S] {
return PropertyRules[T, S]{
name: inferName(),
getter: func(s S) (indirect T, err error) {
ptr := getter(s)
if ptr != nil {
return *ptr, nil
}
zv := *new(T)
return zv, emptyErr{}
},
isPointer: true,
}
}
// Transform transforms value from one type to another.
// Value returned by [PropertyGetter] is transformed through [Transformer] function.
// If [Transformer] returns an error, the validation will not proceed and transformation error will be reported.
// [Transformer] is only called if [PropertyGetter] returns a non-zero value.
func Transform[T, N, S any](getter PropertyGetter[T, S], transform Transformer[T, N]) PropertyRules[N, S] {
typInfo := typeinfo.Get[T]()
return PropertyRules[N, S]{
name: inferName(),
transformGetter: func(s S) (transformed N, original any, err error) {
v := getter(s)
if internal.IsEmpty(v) {
return transformed, nil, emptyErr{}
}
transformed, err = transform(v)
if err != nil {
return transformed, v, NewRuleError(err.Error(), ErrorCodeTransform)
}
return transformed, v, nil
},
originalType: &typInfo,
}
}
// GetSelf is a convenience method for extracting 'self' property of a validated value.
func GetSelf[S any]() PropertyGetter[S, S] {
return func(s S) S { return s }
}
// Transformer is a function that transforms a value of type T to a value of type N.
// If the transformation fails, the function should return an error.
type Transformer[T, N any] func(T) (N, error)
// PropertyGetter is a function that extracts a property value of type T from a given parent value of type S.
type PropertyGetter[T, S any] func(S) T
type (
internalPropertyGetter[T, S any] func(S) (v T, err error)
internalTransformPropertyGetter[T, S any] func(S) (transformed T, original any, err error)
emptyErr struct{}
)
func (emptyErr) Error() string { return "" }
// PropertyRules is responsible for validating a single property.
// It is a collection of rules, predicates, and other properties that define how the property should be validated.
// It is the middle-level building block of the validation process,
// aggregated by [Validator] and aggregating [Rule].
type PropertyRules[T, S any] struct {
name string
getter internalPropertyGetter[T, S]
transformGetter internalTransformPropertyGetter[T, S]
rules []validationInterface[T]
required bool
omitEmpty bool
hideValue bool
isPointer bool
mode CascadeMode
examples []string
originalType *typeinfo.TypeInfo
predicateMatcher[S]
}
// Validate validates the property value using provided rules.
func (r PropertyRules[T, S]) Validate(st S) error {
if !r.matchPredicates(st) {
return nil
}
var (
ruleErrors []error
allErrors PropertyErrors
)
propValue, skip, propErr := r.getValue(st)
if propErr != nil {
if r.hideValue {
propErr = propErr.HideValue()
}
return PropertyErrors{propErr}
}
if skip {
return nil
}
for i := range r.rules {
err := r.rules[i].Validate(propValue)
if err == nil {
continue
}
switch errValue := err.(type) {
// Same as Rule[S] as for GetSelf we'd get the same type on T and S.
case *PropertyError:
allErrors = append(allErrors, errValue.PrependParentPropertyName(r.name))
case *ValidatorError:
for _, e := range errValue.Errors {
allErrors = append(allErrors, e.PrependParentPropertyName(r.name))
}
default:
ruleErrors = append(ruleErrors, err)
}
if r.mode == CascadeModeStop {
break
}
}
if len(ruleErrors) > 0 {
allErrors = append(allErrors, NewPropertyError(r.name, propValue, ruleErrors...))
}
if len(allErrors) > 0 {
if r.hideValue {
allErrors = allErrors.HideValue()
}
return allErrors.aggregate()
}
return nil
}
// WithName sets the name of the property.
// If the name was inferred, it will be overridden.
func (r PropertyRules[T, S]) WithName(name string) PropertyRules[T, S] {
r.name = name
return r
}
// WithExamples sets the examples for the property.
func (r PropertyRules[T, S]) WithExamples(examples ...string) PropertyRules[T, S] {
r.examples = append(r.examples, examples...)
return r
}
// Rules associates provided [Rule] with the property.
func (r PropertyRules[T, S]) Rules(rules ...validationInterface[T]) PropertyRules[T, S] {
r.rules = append(r.rules, rules...)
return r
}
// Include embeds specified [Validator] and its [PropertyRules] into the property.
func (r PropertyRules[T, S]) Include(rules ...Validator[T]) PropertyRules[T, S] {
for _, rule := range rules {
r.rules = append(r.rules, rule)
}
return r
}
// When defines a [Predicate] which determines when the rules for this property should be evaluated.
// It can be called multiple times to set multiple predicates.
// Additionally, it accepts [WhenOptions] which customizes the behavior of the predicate.
func (r PropertyRules[T, S]) When(predicate Predicate[S], opts ...WhenOptions) PropertyRules[T, S] {
r.predicateMatcher = r.when(predicate, opts...)
return r
}
// Required sets the property as required.
// If the property is its type's zero value a [rules.ErrorCodeRequired] will be returned.
func (r PropertyRules[T, S]) Required() PropertyRules[T, S] {
r.required = true
return r
}
// OmitEmpty sets the property rules to be omitted if its value is its type's zero value.
func (r PropertyRules[T, S]) OmitEmpty() PropertyRules[T, S] {
r.omitEmpty = true
return r
}
// HideValue hides the property value in the error message.
// It's useful when the value is sensitive and should not be exposed.
func (r PropertyRules[T, S]) HideValue() PropertyRules[T, S] {
r.hideValue = true
return r
}
// Cascade sets the [CascadeMode] for the property,
// which controls the flow of evaluating the validation rules.
func (r PropertyRules[T, S]) Cascade(mode CascadeMode) PropertyRules[T, S] {
r.mode = mode
return r
}
// cascadeInternal is an internal wrapper around [PropertyRules.Cascade] which
// fulfills [propertyRulesInterface] interface.
// If the [CascadeMode] is already set, it won't change it.
func (r PropertyRules[T, S]) cascadeInternal(mode CascadeMode) propertyRulesInterface[S] {
if r.mode != 0 {
return r
}
return r.Cascade(mode)
}
// plan constructs a validation plan for the property.
func (r PropertyRules[T, S]) plan(builder planBuilder) {
builder.propertyPlan.IsOptional = (r.omitEmpty || r.isPointer) && !r.required
builder.propertyPlan.IsHidden = r.hideValue
for _, predicate := range r.predicates {
builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description)
}
if r.originalType != nil {
builder.propertyPlan.TypeInfo = TypeInfo(*r.originalType)
} else {
builder.propertyPlan.TypeInfo = TypeInfo(typeinfo.Get[T]())
}
builder = builder.appendPath(r.name).setExamples(r.examples...)
for _, rule := range r.rules {
if p, ok := rule.(planner); ok {
p.plan(builder)
}
}
// If we don't have any rules defined for this property, append it nonetheless.
// It can be useful when we have things like [WithExamples] or [Required] set.
if len(r.rules) == 0 {
*builder.children = append(*builder.children, builder)
}
}
// getValue extracts the property value from the provided property.
// It returns the value, a flag indicating whether the validation should be skipped, and any errors encountered.
func (r PropertyRules[T, S]) getValue(st S) (v T, skip bool, propErr *PropertyError) {
var (
err error
originalValue any
)
// Extract value from the property through correct getter.
if r.transformGetter != nil {
v, originalValue, err = r.transformGetter(st)
} else {
v, err = r.getter(st)
}
isEmptyError := errors.Is(err, emptyErr{})
// Any error other than [emptyErr] is considered critical, we don't proceed with validation.
if err != nil && !isEmptyError {
var propValue any
// If the value was transformed, we need to set the property value to the original, pre-transformed one.
if HasErrorCode(err, ErrorCodeTransform) {
propValue = originalValue
} else {
propValue = v
}
return v, false, NewPropertyError(r.name, propValue, err)
}
isEmpty := isEmptyError || (!r.isPointer && internal.IsEmpty(v))
// If the value is not empty we simply return it.
if !isEmpty {
return v, false, nil
}
// If the value is empty and the property is required, we return [ErrorCodeRequired].
if r.required {
return v, false, NewPropertyError(r.name, nil, newRequiredError())
}
// If the value is empty and we're skipping empty values or the value is a pointer, we skip the validation.
if r.omitEmpty || r.isPointer {
return v, true, nil
}
return v, false, nil
}
func newRequiredError() *RuleError {
return NewRuleError(
internal.RequiredErrorMessage,
internal.RequiredErrorCodeString,
)
}
package govy
import (
"fmt"
"github.com/nobl9/govy/internal"
)
// ForMap creates a new [PropertyRulesForMap] instance for a map property
// which value is extracted through [PropertyGetter] function.
func ForMap[M ~map[K]V, K comparable, V, S any](getter PropertyGetter[M, S]) PropertyRulesForMap[M, K, V, S] {
name := inferName()
return PropertyRulesForMap[M, K, V, S]{
mapRules: forConstructor(getter, name),
forKeyRules: forConstructor(GetSelf[K](), ""),
forValueRules: forConstructor(GetSelf[V](), ""),
forItemRules: forConstructor(GetSelf[MapItem[K, V]](), ""),
getter: getter,
}
}
// PropertyRulesForMap is responsible for validating a single property.
type PropertyRulesForMap[M ~map[K]V, K comparable, V, S any] struct {
mapRules PropertyRules[M, S]
forKeyRules PropertyRules[K, K]
forValueRules PropertyRules[V, V]
forItemRules PropertyRules[MapItem[K, V], MapItem[K, V]]
getter PropertyGetter[M, S]
mode CascadeMode
predicateMatcher[S]
}
// MapItem is a tuple container for map's key and value pair.
type MapItem[K comparable, V any] struct {
Key K
Value V
}
// Validate executes each of the rules sequentially and aggregates the encountered errors.
func (r PropertyRulesForMap[M, K, V, S]) Validate(st S) error {
if !r.matchPredicates(st) {
return nil
}
err := r.mapRules.Validate(st)
var propErrs PropertyErrors
if err != nil {
if r.mode == CascadeModeStop {
return err
}
var ok bool
propErrs, ok = err.(PropertyErrors)
if !ok {
logWrongErrorType(PropertyErrors{}, err)
return nil
}
}
for k, v := range r.getter(st) {
if err = r.forKeyRules.Validate(k); err != nil {
if keyErrors, ok := err.(PropertyErrors); ok {
for _, e := range keyErrors {
e.IsKeyError = true
propErrs = append(propErrs, e.PrependParentPropertyName(MapElementName(r.mapRules.name, k)))
}
} else {
logWrongErrorType(PropertyErrors{}, err)
}
}
if err = r.forValueRules.Validate(v); err != nil {
if valueErrors, ok := err.(PropertyErrors); ok {
for _, e := range valueErrors {
propErrs = append(propErrs, e.PrependParentPropertyName(MapElementName(r.mapRules.name, k)))
}
} else {
logWrongErrorType(PropertyErrors{}, err)
}
}
if err = r.forItemRules.Validate(MapItem[K, V]{Key: k, Value: v}); err != nil {
if itemErrors, ok := err.(PropertyErrors); ok {
for _, e := range itemErrors {
// TODO: Figure out how to handle custom PropertyErrors.
// Custom errors' value for nested item will be overridden by the actual value.
e.PropertyValue = internal.PropertyValueString(v)
propErrs = append(propErrs, e.PrependParentPropertyName(MapElementName(r.mapRules.name, k)))
}
} else {
logWrongErrorType(PropertyErrors{}, err)
}
}
}
if len(propErrs) > 0 {
return propErrs.aggregate().sort()
}
return nil
}
// WithName => refer to [PropertyRules.When] documentation.
func (r PropertyRulesForMap[M, K, V, S]) WithName(name string) PropertyRulesForMap[M, K, V, S] {
r.mapRules = r.mapRules.WithName(name)
return r
}
// WithExamples => refer to [PropertyRules.WithExamples] documentation.
func (r PropertyRulesForMap[M, K, V, S]) WithExamples(examples ...string) PropertyRulesForMap[M, K, V, S] {
r.mapRules = r.mapRules.WithExamples(examples...)
return r
}
// RulesForKeys adds [Rule] for map's keys.
func (r PropertyRulesForMap[M, K, V, S]) RulesForKeys(
rules ...validationInterface[K],
) PropertyRulesForMap[M, K, V, S] {
r.forKeyRules = r.forKeyRules.Rules(rules...)
return r
}
// RulesForValues adds [Rule] for map's values.
func (r PropertyRulesForMap[M, K, V, S]) RulesForValues(
rules ...validationInterface[V],
) PropertyRulesForMap[M, K, V, S] {
r.forValueRules = r.forValueRules.Rules(rules...)
return r
}
// RulesForItems adds [Rule] for [MapItem].
// It allows validating both key and value in conjunction.
func (r PropertyRulesForMap[M, K, V, S]) RulesForItems(
rules ...validationInterface[MapItem[K, V]],
) PropertyRulesForMap[M, K, V, S] {
r.forItemRules = r.forItemRules.Rules(rules...)
return r
}
// Rules adds [Rule] for the whole map.
func (r PropertyRulesForMap[M, K, V, S]) Rules(rules ...validationInterface[M]) PropertyRulesForMap[M, K, V, S] {
r.mapRules = r.mapRules.Rules(rules...)
return r
}
// When => refer to [PropertyRules.When] documentation.
func (r PropertyRulesForMap[M, K, V, S]) When(
predicate Predicate[S],
opts ...WhenOptions,
) PropertyRulesForMap[M, K, V, S] {
r.predicateMatcher = r.when(predicate, opts...)
return r
}
// IncludeForKeys associates specified [Validator] and its [PropertyRules] with map's keys.
func (r PropertyRulesForMap[M, K, V, S]) IncludeForKeys(validators ...Validator[K]) PropertyRulesForMap[M, K, V, S] {
r.forKeyRules = r.forKeyRules.Include(validators...)
return r
}
// IncludeForValues associates specified [Validator] and its [PropertyRules] with map's values.
func (r PropertyRulesForMap[M, K, V, S]) IncludeForValues(rules ...Validator[V]) PropertyRulesForMap[M, K, V, S] {
r.forValueRules = r.forValueRules.Include(rules...)
return r
}
// IncludeForItems associates specified [Validator] and its [PropertyRules] with [MapItem].
// It allows validating both key and value in conjunction.
func (r PropertyRulesForMap[M, K, V, S]) IncludeForItems(
rules ...Validator[MapItem[K, V]],
) PropertyRulesForMap[M, K, V, S] {
r.forItemRules = r.forItemRules.Include(rules...)
return r
}
// Cascade => refer to [PropertyRules.Cascade] documentation.
func (r PropertyRulesForMap[M, K, V, S]) Cascade(mode CascadeMode) PropertyRulesForMap[M, K, V, S] {
r.mode = mode
r.mapRules = r.mapRules.Cascade(mode)
r.forKeyRules = r.forKeyRules.Cascade(mode)
r.forValueRules = r.forValueRules.Cascade(mode)
r.forItemRules = r.forItemRules.Cascade(mode)
return r
}
// cascadeInternal is an internal wrapper around [PropertyRulesForMap.Cascade] which
// fulfills [propertyRulesInterface] interface.
// If the [CascadeMode] is already set, it won't change it.
func (r PropertyRulesForMap[M, K, V, S]) cascadeInternal(mode CascadeMode) propertyRulesInterface[S] {
if r.mode != 0 {
return r
}
return r.Cascade(mode)
}
// plan constructs a validation plan for the property rules.
func (r PropertyRulesForMap[M, K, V, S]) plan(builder planBuilder) {
for _, predicate := range r.predicates {
builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description)
}
r.mapRules.plan(builder.setExamples(r.mapRules.examples...))
builder = builder.appendPath(r.mapRules.name)
// JSON/YAML path for keys uses '~' to extract the keys.
if len(r.forKeyRules.rules) > 0 {
r.forKeyRules.plan(builder.appendPath("~"))
}
if len(r.forValueRules.rules) > 0 {
r.forValueRules.plan(builder.appendPath("*"))
}
if len(r.forItemRules.rules) > 0 {
r.forItemRules.plan(builder.appendPath("*"))
}
}
// MapElementName generates a name for a map element denoted by its key.
func MapElementName(mapName, key any) string {
if mapName == "" {
return fmt.Sprintf("%v", key)
}
return fmt.Sprintf("%s.%v", mapName, key)
}
package govy
import (
"fmt"
)
// ForSlice creates a new [PropertyRulesForSlice] instance for a slice property
// which value is extracted through [PropertyGetter] function.
func ForSlice[T, S any](getter PropertyGetter[[]T, S]) PropertyRulesForSlice[T, S] {
name := inferName()
return PropertyRulesForSlice[T, S]{
sliceRules: forConstructor(GetSelf[[]T](), name),
forEachRules: forConstructor(GetSelf[T](), ""),
getter: getter,
}
}
// PropertyRulesForSlice is responsible for validating a single property.
type PropertyRulesForSlice[T, S any] struct {
sliceRules PropertyRules[[]T, []T]
forEachRules PropertyRules[T, T]
getter PropertyGetter[[]T, S]
mode CascadeMode
predicateMatcher[S]
}
// Validate executes each of the rules sequentially and aggregates the encountered errors.
func (r PropertyRulesForSlice[T, S]) Validate(st S) error {
if !r.matchPredicates(st) {
return nil
}
v := r.getter(st)
err := r.sliceRules.Validate(v)
var propErrs PropertyErrors
if err != nil {
if r.mode == CascadeModeStop {
return err
}
var ok bool
propErrs, ok = err.(PropertyErrors)
if !ok {
logWrongErrorType(PropertyErrors{}, err)
return nil
}
}
for i, element := range v {
err = r.forEachRules.Validate(element)
if err == nil {
continue
}
forEachErrors, ok := err.(PropertyErrors)
if !ok {
logWrongErrorType(PropertyErrors{}, err)
continue
}
for _, e := range forEachErrors {
e.IsSliceElementError = true
propErrs = append(propErrs, e.PrependParentPropertyName(SliceElementName(r.sliceRules.name, i)))
}
}
if len(propErrs) > 0 {
return propErrs.aggregate()
}
return nil
}
// WithName => refer to [PropertyRules.WithName] documentation.
func (r PropertyRulesForSlice[T, S]) WithName(name string) PropertyRulesForSlice[T, S] {
r.sliceRules = r.sliceRules.WithName(name)
return r
}
// WithExamples => refer to [PropertyRules.WithExamples] documentation.
func (r PropertyRulesForSlice[T, S]) WithExamples(examples ...string) PropertyRulesForSlice[T, S] {
r.sliceRules = r.sliceRules.WithExamples(examples...)
return r
}
// RulesForEach adds [Rule] for each element of the slice.
func (r PropertyRulesForSlice[T, S]) RulesForEach(rules ...validationInterface[T]) PropertyRulesForSlice[T, S] {
r.forEachRules = r.forEachRules.Rules(rules...)
return r
}
// Rules adds [Rule] for the whole slice.
func (r PropertyRulesForSlice[T, S]) Rules(rules ...validationInterface[[]T]) PropertyRulesForSlice[T, S] {
r.sliceRules = r.sliceRules.Rules(rules...)
return r
}
// When => refer to [PropertyRules.When] documentation.
func (r PropertyRulesForSlice[T, S]) When(predicate Predicate[S], opts ...WhenOptions) PropertyRulesForSlice[T, S] {
r.predicateMatcher = r.when(predicate, opts...)
return r
}
// IncludeForEach associates specified [Validator] and its [PropertyRules] with each element of the slice.
func (r PropertyRulesForSlice[T, S]) IncludeForEach(rules ...Validator[T]) PropertyRulesForSlice[T, S] {
r.forEachRules = r.forEachRules.Include(rules...)
return r
}
// Cascade => refer to [PropertyRules.Cascade] documentation.
func (r PropertyRulesForSlice[T, S]) Cascade(mode CascadeMode) PropertyRulesForSlice[T, S] {
r.mode = mode
r.sliceRules = r.sliceRules.Cascade(mode)
r.forEachRules = r.forEachRules.Cascade(mode)
return r
}
// cascadeInternal is an internal wrapper around [PropertyRulesForMap.Cascade] which
// fulfills [propertyRulesInterface] interface.
// If the [CascadeMode] is already set, it won't change it.
func (r PropertyRulesForSlice[T, S]) cascadeInternal(mode CascadeMode) propertyRulesInterface[S] {
if r.mode != 0 {
return r
}
return r.Cascade(mode)
}
// plan generates a validation plan for the property rules.
func (r PropertyRulesForSlice[T, S]) plan(builder planBuilder) {
for _, predicate := range r.predicates {
builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description)
}
r.sliceRules.plan(builder.setExamples(r.sliceRules.examples...))
builder = builder.appendPath(r.sliceRules.name)
if len(r.forEachRules.rules) > 0 {
r.forEachRules.plan(builder.appendPath("[*]"))
}
}
// SliceElementName generates a name for a slice element.
func SliceElementName(sliceName string, index int) string {
if sliceName == "" {
return fmt.Sprintf("[%d]", index)
}
return fmt.Sprintf("%s[%d]", sliceName, index)
}
package govy
import (
"fmt"
"strings"
)
// validationInterface is a common interface implemented by all validation entities.
// These include [Validator], [PropertyRules] and [Rule].
type validationInterface[T any] interface {
Validate(s T) error
}
// propertyRulesInterface is an internal interface which further limits
// what [New] constructor and [Validator] can accept as property rules.
//
// On top of [validationInterface] requirements it specifies internal functions
// which allow interacting with [propertyRulesInterface] instances like [PropertyRules]
// in an immutable fashion (no pointer receivers).
type propertyRulesInterface[T any] interface {
validationInterface[T]
cascadeInternal(mode CascadeMode) propertyRulesInterface[T]
}
// New creates a new [Validator] aggregating the provided property rules.
func New[S any](props ...propertyRulesInterface[S]) Validator[S] {
return Validator[S]{props: props}
}
// Validator is the top level validation entity.
// It serves as an aggregator for [PropertyRules].
// Typically, it represents a struct.
type Validator[S any] struct {
props []propertyRulesInterface[S]
name string
nameFunc func(S) string
mode CascadeMode
predicateMatcher[S]
}
// WithName when a rule fails will pass the provided name to [ValidatorError.WithName].
func (v Validator[S]) WithName(name string) Validator[S] {
v.nameFunc = nil
v.name = name
return v
}
// WithNameFunc when a rule fails extracts name from provided function and passes it to [ValidatorError.WithName].
// The function receives validated entity's instance as an argument.
func (v Validator[S]) WithNameFunc(f func(s S) string) Validator[S] {
v.name = ""
v.nameFunc = f
return v
}
// When accepts predicates which will be evaluated BEFORE [Validator] validates ANY rules.
func (v Validator[S]) When(predicate Predicate[S], opts ...WhenOptions) Validator[S] {
v.predicateMatcher = v.when(predicate, opts...)
return v
}
// InferName will set the name of the [Validator] to its type S.
// If the name was already set through [Validator.WithName], it will not be overridden.
// It does not use the same inference mechanisms as [PropertyRules.InferName],
// it simply checks the [Validator] type parameter using reflection.
func (v Validator[S]) InferName() Validator[S] {
if v.name != "" {
return v
}
split := strings.Split(fmt.Sprintf("%T", *new(S)), ".")
if len(split) == 0 {
return v
}
v.name = split[len(split)-1]
return v
}
// Cascade sets the [CascadeMode] for the validator,
// which controls the flow of evaluating the validation rules.
func (v Validator[S]) Cascade(mode CascadeMode) Validator[S] {
v.mode = mode
props := make([]propertyRulesInterface[S], 0, len(v.props))
for _, prop := range v.props {
props = append(props, prop.cascadeInternal(mode))
}
v.props = props
return v
}
// Validate will first evaluate predicates before validating any rules.
// If any predicate does not pass the validation won't be executed (returns nil).
// All errors returned by property rules will be aggregated and wrapped in [ValidatorError].
func (v Validator[S]) Validate(st S) error {
if !v.matchPredicates(st) {
return nil
}
var allErrors PropertyErrors
for _, rules := range v.props {
err := rules.Validate(st)
if err == nil {
continue
}
pErrs, ok := err.(PropertyErrors)
if !ok {
logWrongErrorType(PropertyErrors{}, err)
continue
}
allErrors = append(allErrors, pErrs...)
if v.mode == CascadeModeStop {
break
}
}
if len(allErrors) != 0 {
name := v.name
if v.nameFunc != nil {
name = v.nameFunc(st)
}
return NewValidatorError(allErrors).WithName(name)
}
return nil
}
// ValidatorSlice is used to validate a slice of values of the type S.
// Under the hood [Validator.Validate] is called for each element and the errors
// are aggregated into [ValidatorErrors].
//
// Note: It is designed to be used for validating independent values.
// If you need to validate the slice itself, for instance, to check if it has at most N elements,
// you should use the [Validator] directly in tandem with [ForSlice] and [GetSelf].
func (v Validator[S]) ValidateSlice(s []S) error {
errs := make(ValidatorErrors, 0)
for i, st := range s {
if err := v.Validate(st); err != nil {
vErr, ok := err.(*ValidatorError)
if !ok {
return err
}
vErr.SliceIndex = &i
errs = append(errs, vErr)
}
}
if len(errs) == 0 {
return nil
}
return errs
}
// plan constructs a validation plan for all the properties of the [Validator].
func (v Validator[S]) plan(builder planBuilder) {
for _, predicate := range v.predicates {
builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description)
}
for _, rules := range v.props {
if p, ok := rules.(planner); ok {
p.plan(builder)
}
}
}
package govyconfig
import (
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/logging"
)
var (
inferredNames = make(map[string]InferredName)
nameInferFunc NameInferFunc = NameInferDefaultFunc
nameInferMode = NameInferModeDisable
includeTestFiles = false
mu sync.RWMutex
)
// InferredName represents an inferred property name.
type InferredName struct {
// Name is the inferred property name.
Name string
// File is the relative path to the file where the [govy.PropertyRules.For] is detected.
File string
// Line is the line number in the File where the [govy.PropertyRules.For] is detected.
Line int
}
func (g InferredName) key() string {
return getterLocationKey(g.File, g.Line)
}
func getterLocationKey(file string, line int) string {
file = strings.TrimPrefix(file, internal.FindModuleRoot()+"/")
return fmt.Sprintf("%s:%d", file, line)
}
// SetInferredName sets the inferred property name for the given file and line.
// Once it's registered it can be retrieved using [GetInferredName].
// It is primarily exported for code generation utility of govy which runs in NameInferModeGenerate.
func SetInferredName(loc InferredName) {
mu.Lock()
inferredNames[loc.key()] = loc
mu.Unlock()
}
// GetInferredName returns the inferred property name for the given file and line.
// The name has to be first set using [SetInferredName].
// It is primarily exported for govy to utilize when NameInferModeGenerate mode is set.
func GetInferredName(file string, line int) string {
mu.RLock()
defer mu.RUnlock()
name, ok := inferredNames[getterLocationKey(file, line)]
if !ok {
logging.Logger().Error(
"inferred name was not found",
slog.String("file", file),
slog.Int("line", line),
)
return ""
}
return name.Name
}
// SetLogLevel sets the logging level for [slog.Logger] used by govy.
// It's safe to call this function concurrently.
func SetLogLevel(level slog.Level) {
logging.SetLogLevel(level)
}
// NameInferMode defines a mode of property names' inference.
type NameInferMode int
const (
// NameInferModeDisable disables property names' inference.
// It is the default mode.
NameInferModeDisable NameInferMode = iota
// NameInferModeRuntime infers property names' during runtime,
// whenever For, ForSlice, ForPointer or ForMap are created.
// If you're not reusing these [govy.PropertyRules], but rather creating them dynamically,
// beware of significant performance cost of the inference mechanism.
NameInferModeRuntime
// NameInferModeGenerate does the heavy lifting of inferring property names
// in a separate step which involves code generation.
// When creating new [govy.PropertyRules], the only performance hit is due to the
// usage of [runtime] package which helps us get the caller frame details.
NameInferModeGenerate
)
// SetNameInferMode sets the mode of property names' inference.
// It overrides the default mode [NameInferModeDisable].
// It's safe to call this function concurrently.
func SetNameInferMode(mode NameInferMode) {
mu.Lock()
nameInferMode = mode
mu.Unlock()
}
func GetNameInferMode() NameInferMode {
mu.RLock()
defer mu.RUnlock()
return nameInferMode
}
// SetNameInferFunc sets the function for inferring field names from struct tags.
// It overrides the default function [NameInferDefaultFunc].
// It's safe to call this function concurrently.
func SetNameInferFunc(rule NameInferFunc) {
mu.Lock()
nameInferFunc = rule
mu.Unlock()
}
func GetNameInferFunc() NameInferFunc {
mu.RLock()
defer mu.RUnlock()
return nameInferFunc
}
// NameInferFunc is a function blueprint for inferring property names.
// It is only called for struct fields.
// Tag value is the raw value of the struct tag, it needs to be parsed with [reflect.StructTag].
type NameInferFunc func(fieldName, tagValue string) string
// NameInferDefaultFunc is the default function for inferring field names from struct tags.
// It looks for json and yaml tags, preferring json if both are set.
func NameInferDefaultFunc(fieldName, tagValue string) string {
for _, tagKey := range []string{"json", "yaml"} {
tagValues := strings.Split(
reflect.StructTag(strings.Trim(tagValue, "`")).Get(tagKey),
",",
)
if len(tagValues) > 0 && tagValues[0] != "" {
fieldName = tagValues[0]
break
}
}
return fieldName
}
// SetNameInferIncludeTestFiles sets whether to include test files in name inference mechanism.
func SetNameInferIncludeTestFiles(inc bool) {
mu.Lock()
includeTestFiles = inc
mu.Unlock()
}
// GetNameInferIncludeTestFiles returns whether to include test files in name inference mechanism.
func GetNameInferIncludeTestFiles() bool {
mu.RLock()
defer mu.RUnlock()
return includeTestFiles
}
package govytest
import (
"encoding/json"
"strings"
"github.com/nobl9/govy/pkg/govy"
"github.com/nobl9/govy/pkg/rules"
)
// testingT is an interface that is compatible with *testing.T.
// It is used to make the functions in this package testable.
type testingT interface {
Errorf(format string, args ...any)
Error(args ...any)
Helper()
}
// ExpectedRuleError defines the expectations for the asserted error.
// Its fields are used to find and match an actual [govy.RuleError].
type ExpectedRuleError struct {
// Optional. Matched against [govy.PropertyError.PropertyName].
// It should be only left empty if the validate property has no name.
PropertyName string `json:"propertyName"`
// Optional. Matched against [govy.RuleError.Code].
Code govy.ErrorCode `json:"code,omitempty"`
// Optional. Matched against [govy.RuleError.Message].
Message string `json:"message,omitempty"`
// Optional. Matched against [govy.RuleError.Message] (partial).
ContainsMessage string `json:"containsMessage,omitempty"`
// Optional. Matched against [govy.PropertyError.IsKeyError].
IsKeyError bool `json:"isKeyError,omitempty"`
}
// expectedRuleErrorValidation defines the validation rules for [ExpectedRuleError].
var expectedRuleErrorValidation = govy.New(
govy.For(govy.GetSelf[ExpectedRuleError]()).
Rules(rules.OneOfProperties(map[string]func(e ExpectedRuleError) any{
"code": func(e ExpectedRuleError) any { return e.Code },
"message": func(e ExpectedRuleError) any { return e.Message },
"containsMessage": func(e ExpectedRuleError) any { return e.ContainsMessage },
})),
).InferName()
// Validate checks if the [ExpectedRuleError] is valid.
func (e ExpectedRuleError) Validate() error {
return expectedRuleErrorValidation.Validate(e)
}
// AssertNoError asserts that the provided error is nil.
// If the error is not nil and of type [govy.ValidatorError] it will try
// encoding it to JSON and pretty printing the encountered error.
//
// It returns true if the error is nil, false otherwise.
func AssertNoError(t testingT, err error) bool {
t.Helper()
if err == nil {
return true
}
errMsg := err.Error()
if vErr, ok := err.(*govy.ValidatorError); ok {
encErr, _ := json.MarshalIndent(vErr, "", " ")
errMsg = string(encErr)
}
t.Errorf("Received unexpected error:\n%+s", errMsg)
return false
}
// AssertError asserts that the given error has:
// - type equal to [*govy.ValidatorError]
// - the expected number of [govy.RuleError] equal to the number of provided [ExpectedRuleError]
// - at least one error which matches each of the provided [ExpectedRuleError]
//
// [ExpectedRuleError] and actual error are considered equal if they have the same property name and:
// - [ExpectedRuleError.Code] is equal to [govy.RuleError.Code]
// - [ExpectedRuleError.Message] is equal to [govy.RuleError.Message]
// - [ExpectedRuleError.ContainsMessage] is part of [govy.RuleError.Message]
//
// At least one of the above must be set for [ExpectedRuleError]
// and once set, it will need to match the actual error.
//
// If [ExpectedRuleError.IsKeyError] is provided it will be required to match
// the actual [govy.PropertyError.IsKeyError].
//
// It returns true if the error matches the expectations, false otherwise.
func AssertError(
t testingT,
err error,
expectedErrors ...ExpectedRuleError,
) bool {
t.Helper()
return assertError(t, true, err, expectedErrors...)
}
// AssertErrorContains asserts that the given error has:
// - type equal to [*govy.ValidatorError]
// - at least one error which matches the provided [ExpectedRuleError]
//
// Unlike [AssertError], it checks only a single error.
// The actual error may contain other errors, If you want to match them all, use [AssertError].
//
// [ExpectedRuleError] and actual error are considered equal if they have the same property name and:
// - [ExpectedRuleError.Code] is equal to [govy.RuleError.Code]
// - [ExpectedRuleError.Message] is equal to [govy.RuleError.Message]
// - [ExpectedRuleError.ContainsMessage] is part of [govy.RuleError.Message]
//
// At least one of the above must be set for [ExpectedRuleError]
// and once set, it will need to match the actual error.
//
// If [ExpectedRuleError.IsKeyError] is provided it will be required to match
// the actual [govy.PropertyError.IsKeyError].
//
// It returns true if the error matches the expectations, false otherwise.
func AssertErrorContains(
t testingT,
err error,
expectedError ExpectedRuleError,
) bool {
t.Helper()
return assertError(t, false, err, expectedError)
}
func assertError(
t testingT,
countErrors bool,
err error,
expectedErrors ...ExpectedRuleError,
) bool {
t.Helper()
if !validateExpectedErrors(t, expectedErrors...) {
return false
}
validatorErr, ok := assertValidatorError(t, err)
if !ok {
return false
}
if countErrors {
if !assertErrorsCount(t, validatorErr, len(expectedErrors)) {
return false
}
}
matched := make(matchedErrors, len(expectedErrors))
for _, expected := range expectedErrors {
if !assertErrorMatches(t, validatorErr, expected, matched) {
return false
}
}
return true
}
func validateExpectedErrors(t testingT, expectedErrors ...ExpectedRuleError) bool {
t.Helper()
if len(expectedErrors) == 0 {
t.Errorf("%T must not be empty.", expectedErrors)
return false
}
for _, expected := range expectedErrors {
if err := expected.Validate(); err != nil {
t.Error(err.Error())
return false
}
}
return true
}
func assertValidatorError(t testingT, err error) (*govy.ValidatorError, bool) {
t.Helper()
if err == nil {
t.Errorf("Input error should not be nil.")
return nil, false
}
validatorErr, ok := err.(*govy.ValidatorError)
if !ok {
t.Errorf("Input error should be of type %T.", &govy.ValidatorError{})
}
return validatorErr, ok
}
func assertErrorsCount(
t testingT,
validatorErr *govy.ValidatorError,
expectedErrorsCount int,
) bool {
t.Helper()
actualErrorsCount := 0
for _, actual := range validatorErr.Errors {
actualErrorsCount += len(actual.Errors)
}
if expectedErrorsCount != actualErrorsCount {
t.Errorf("%T contains different number of errors than expected, expected: %d, actual: %d.",
validatorErr, expectedErrorsCount, actualErrorsCount)
return false
}
return true
}
type matchedErrors map[int]map[int]struct{}
func (m matchedErrors) Add(propertyErrorIdx, ruleErrorIdx int) bool {
if _, ok := m[propertyErrorIdx]; !ok {
m[propertyErrorIdx] = make(map[int]struct{})
}
_, ok := m[propertyErrorIdx][ruleErrorIdx]
m[propertyErrorIdx][ruleErrorIdx] = struct{}{}
return ok
}
func assertErrorMatches(
t testingT,
validatorErr *govy.ValidatorError,
expected ExpectedRuleError,
matched matchedErrors,
) bool {
t.Helper()
multiMatch := false
for i, actual := range validatorErr.Errors {
if actual.PropertyName != expected.PropertyName {
continue
}
if expected.IsKeyError != actual.IsKeyError {
continue
}
for j, actualRuleErr := range actual.Errors {
actualMessage := actualRuleErr.Error()
matchedCtr := 0
if expected.Message == "" || expected.Message == actualMessage {
matchedCtr++
}
if expected.ContainsMessage == "" ||
strings.Contains(actualMessage, expected.ContainsMessage) {
matchedCtr++
}
if expected.Code == "" ||
expected.Code == actualRuleErr.Code ||
govy.HasErrorCode(actualRuleErr, expected.Code) {
matchedCtr++
}
if matchedCtr == 3 {
if matched.Add(i, j) {
multiMatch = true
continue
}
return true
}
}
}
if multiMatch {
t.Errorf("Actual error was matched multiple times. Consider providing a more specific %T list.", expected)
return false
}
encExpected, _ := json.MarshalIndent(expected, "", " ")
encActual, _ := json.MarshalIndent(validatorErr.Errors, "", " ")
t.Errorf("Expected error was not found.\nEXPECTED:\n%s\nACTUAL:\n%s",
string(encExpected), string(encActual))
return false
}
package rules
import (
"fmt"
"reflect"
"strings"
"golang.org/x/exp/constraints"
"github.com/nobl9/govy/internal/collections"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// EQ ensures the property's value is equal to the compared value.
func EQ[T comparable](compared T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.EQTemplate)
return govy.NewRule(func(v T) error {
if v != compared {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: compared,
})
}
return nil
}).
WithErrorCode(ErrorCodeEqualTo).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: compared,
}))
}
// NEQ ensures the property's value is not equal to the compared value.
func NEQ[T comparable](compared T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.NEQTemplate)
return govy.NewRule(func(v T) error {
if v == compared {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: compared,
})
}
return nil
}).
WithErrorCode(ErrorCodeNotEqualTo).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: compared,
}))
}
// GT ensures the property's value is greater than the compared value.
func GT[T constraints.Ordered](compared T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.GTTemplate)
return govy.NewRule(func(v T) error {
if v <= compared {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: compared,
})
}
return nil
}).
WithErrorCode(ErrorCodeGreaterThan).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: compared,
}))
}
// GTE ensures the property's value is greater than or equal to the compared value.
func GTE[T constraints.Ordered](compared T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.GTETemplate)
return govy.NewRule(func(v T) error {
if v < compared {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: compared,
})
}
return nil
}).
WithErrorCode(ErrorCodeGreaterThanOrEqualTo).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: compared,
}))
}
// LT ensures the property's value is less than the compared value.
func LT[T constraints.Ordered](compared T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.LTTemplate)
return govy.NewRule(func(v T) error {
if v >= compared {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: compared,
})
}
return nil
}).
WithErrorCode(ErrorCodeLessThan).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: compared,
}))
}
// LTE ensures the property's value is less than or equal to the compared value.
func LTE[T constraints.Ordered](compared T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.LTETemplate)
return govy.NewRule(func(v T) error {
if v > compared {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: compared,
})
}
return nil
}).
WithErrorCode(ErrorCodeLessThanOrEqualTo).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: compared,
}))
}
// ComparisonFunc defines a shape for a function that compares two values.
// It should return true if the values are equal, false otherwise.
type ComparisonFunc[T any] func(v1, v2 T) bool
// CompareFunc compares two values of the same type.
// The type is constrained by the [comparable] interface.
func CompareFunc[T comparable](v1, v2 T) bool {
return v1 == v2
}
// CompareDeepEqualFunc compares two values of the same type using [reflect.DeepEqual].
// It is particularly useful when comparing pointers' values.
func CompareDeepEqualFunc[T any](v1, v2 T) bool {
return reflect.DeepEqual(v1, v2)
}
type equalPropertiesTemplateVars struct {
FirstNotEqual string
SecondNotEqual string
}
// EqualProperties checks if all the specified properties are equal.
// It uses the provided [ComparisonFunc] to compare the values.
// The following built-in comparison functions are available:
// - [CompareFunc]
// - [CompareDeepEqualFunc]
//
// If builtin [ComparisonFunc] is not enough, a custom function can be used.
func EqualProperties[S, T any](compare ComparisonFunc[T], getters map[string]func(s S) T) govy.Rule[S] {
tpl := messagetemplates.Get(messagetemplates.EqualPropertiesTemplate)
sortedKeys := collections.SortedKeys(getters)
return govy.NewRule(func(s S) error {
if len(getters) < 2 {
return nil
}
var (
i = 0
lastValue T
lastProp string
)
for _, prop := range sortedKeys {
v := getters[prop](s)
if i != 0 && !compare(v, lastValue) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
ComparisonValue: sortedKeys,
Custom: equalPropertiesTemplateVars{
FirstNotEqual: lastProp,
SecondNotEqual: prop,
},
})
}
lastProp = prop
lastValue = v
i++
}
return nil
}).
WithErrorCode(ErrorCodeEqualProperties).
WithMessageTemplate(tpl).
WithDescription(func() string {
return fmt.Sprintf(
"all of the properties must be equal: %s",
strings.Join(collections.SortedKeys(getters), ", "),
)
}())
}
package rules
import (
"errors"
"strconv"
"strings"
)
var errInvalidCrontab = errors.New("invalid crontab expression")
var crontabMonthsMap = map[string]int{
"JAN": 1, "FEB": 2, "MAR": 3, "APR": 4,
"MAY": 5, "JUN": 6, "JUL": 7, "AUG": 8,
"SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12,
}
var crontabDaysMap = map[string]int{
"SUN": 0, "MON": 1, "TUE": 2, "WED": 3,
"THU": 4, "FRI": 5, "SAT": 6,
}
func parseCrontab(c string) error {
if strings.HasPrefix(c, "@") {
switch c {
case "@reboot", "@yearly", "@annually", "@monthly", "@weekly", "@daily", "@hourly":
return nil
default:
return errInvalidCrontab
}
}
fields := strings.Fields(c)
if len(fields) != 5 {
return errors.New("crontab expression must have exactly 5 fields")
}
for i, field := range fields {
if field == "*" {
continue
}
if strings.HasPrefix(field, "*/") {
if len(field) < 3 {
return errInvalidCrontab
}
if _, err := strconv.Atoi(field[2:]); err != nil {
return errInvalidCrontab
}
continue
}
switch i {
case 0:
if !validateCrontabField(field, 0, 59, crontabParseStandardField) {
return errInvalidCrontab
}
case 1:
if !validateCrontabField(field, 0, 23, crontabParseStandardField) {
return errInvalidCrontab
}
case 2:
if !validateCrontabField(field, 1, 31, crontabParseStandardField) {
return errInvalidCrontab
}
case 3:
if !validateCrontabField(field, 1, 12, crontabParseMonthField) {
return errInvalidCrontab
}
case 4:
if !validateCrontabField(field, 0, 7, crontabParseDayField) {
return errInvalidCrontab
}
}
}
return nil
}
type crontabFieldParseFunc func(string) (int, bool)
func crontabParseStandardField(v string) (int, bool) {
if v == "" {
return -1, false
}
i, err := strconv.Atoi(v)
if err != nil {
return -1, false
}
return i, true
}
func crontabParseMonthField(v string) (int, bool) {
if v == "" {
return -1, false
}
if i, ok := crontabMonthsMap[strings.ToUpper(v)]; ok {
return i, true
}
return crontabParseStandardField(v)
}
func crontabParseDayField(v string) (int, bool) {
if v == "" {
return -1, false
}
if i, ok := crontabDaysMap[strings.ToUpper(v)]; ok {
return i, true
}
return crontabParseStandardField(v)
}
func validateCrontabField(field string, lowerLimit, upperLimit int, parse crontabFieldParseFunc) bool {
for _, el := range strings.Split(field, ",") {
rangeIdx := strings.Index(el, "-")
if rangeIdx != -1 {
if rangeIdx == 0 || rangeIdx == len(el)-1 {
return false
}
// Check lower range bound.
l, ok := parse(el[:rangeIdx])
if !ok {
return false
}
if l < lowerLimit || l > upperLimit {
return false
}
// Take step value into account.
stepIdx := strings.Index(el, "/")
switch {
case stepIdx == -1:
stepIdx = len(el)
case stepIdx == len(el)-1:
return false
default:
if v, err := strconv.Atoi(el[stepIdx+1:]); err != nil || v < 0 {
return false
}
}
// Check upper range bound.
u, ok := parse(el[rangeIdx+1 : stepIdx])
if !ok {
return false
}
if u < lowerLimit || u > upperLimit {
return false
}
// Compare lower and upper bounds.
if l > u {
return false
}
break
} else {
v, ok := parse(el)
if !ok {
return false
}
if v < lowerLimit || v > upperLimit {
return false
}
}
}
return true
}
package rules
import (
"time"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// DurationPrecision ensures the duration is defined with the specified precision.
func DurationPrecision(precision time.Duration) govy.Rule[time.Duration] {
if precision <= 0 {
panic("precision must be greater than 0")
}
tpl := messagetemplates.Get(messagetemplates.DurationPrecisionTemplate)
return govy.NewRule(func(v time.Duration) error {
if v%precision != 0 {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: precision,
})
}
return nil
}).
WithErrorCode(ErrorCodeDurationPrecision).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
PropertyValue: precision,
}))
}
package rules
import (
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// Forbidden ensures the property's value is its type's zero value, i.e. it's empty.
func Forbidden[T any]() govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.ForbiddenTemplate)
return govy.NewRule(func(v T) error {
if internal.IsEmpty(v) {
return nil
}
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
})
}).
WithErrorCode(ErrorCodeForbidden).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
package rules
import "strconv"
// ordinalString returns the ordinal representation of an integer.
// Stolen from: https://github.com/dustin/go-humanize.
func ordinalString(x int) string {
suffix := "th"
switch x % 10 {
case 1:
if x%100 != 11 {
suffix = "st"
}
case 2:
if x%100 != 12 {
suffix = "nd"
}
case 3:
if x%100 != 13 {
suffix = "rd"
}
}
return strconv.Itoa(x) + suffix
}
package rules
import (
"fmt"
"unicode/utf8"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// StringLength ensures the string's length is between min and max (closed interval).
//
// The following, additional template variables are supported:
// - [govy.TemplateVars.MinLength]
// - [govy.TemplateVars.MaxLength]
func StringLength(minLen, maxLen int) govy.Rule[string] {
enforceMinMaxLength(minLen, maxLen)
tpl := messagetemplates.Get(messagetemplates.LengthTemplate)
return govy.NewRule(func(v string) error {
length := utf8.RuneCountInString(v)
if length < minLen || length > maxLen {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
MinLength: minLen,
MaxLength: maxLen,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
MinLength: minLen,
MaxLength: maxLen,
}))
}
// StringMinLength ensures the string's length is greater than or equal to the limit.
func StringMinLength(limit int) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.MinLengthTemplate)
return govy.NewRule(func(v string) error {
length := utf8.RuneCountInString(v)
if length < limit {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: limit,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringMinLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: limit,
}))
}
// StringMaxLength ensures the string's length is less than or equal to the limit.
func StringMaxLength(limit int) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.MaxLengthTemplate)
return govy.NewRule(func(v string) error {
length := utf8.RuneCountInString(v)
if length > limit {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: limit,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringMaxLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: limit,
}))
}
// SliceLength ensures the slice's length is between min and max (closed interval).
//
// The following, additional template variables are supported:
// - [govy.TemplateVars.MinLength]
// - [govy.TemplateVars.MaxLength]
func SliceLength[S ~[]E, E any](minLen, maxLen int) govy.Rule[S] {
enforceMinMaxLength(minLen, maxLen)
tpl := messagetemplates.Get(messagetemplates.LengthTemplate)
return govy.NewRule(func(v S) error {
length := len(v)
if length < minLen || length > maxLen {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
MinLength: minLen,
MaxLength: maxLen,
})
}
return nil
}).
WithErrorCode(ErrorCodeSliceLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
MinLength: minLen,
MaxLength: maxLen,
}))
}
// SliceMinLength ensures the slice's length is greater than or equal to the limit.
func SliceMinLength[S ~[]E, E any](limit int) govy.Rule[S] {
tpl := messagetemplates.Get(messagetemplates.MinLengthTemplate)
return govy.NewRule(func(v S) error {
length := len(v)
if length < limit {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: limit,
})
}
return nil
}).
WithErrorCode(ErrorCodeSliceMinLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: limit,
}))
}
// SliceMaxLength ensures the slice's length is less than or equal to the limit.
func SliceMaxLength[S ~[]E, E any](limit int) govy.Rule[S] {
tpl := messagetemplates.Get(messagetemplates.MaxLengthTemplate)
return govy.NewRule(func(v S) error {
length := len(v)
if length > limit {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: limit,
})
}
return nil
}).
WithErrorCode(ErrorCodeSliceMaxLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: limit,
}))
}
// MapLength ensures the map's length is between min and max (closed interval).
//
// The following, additional template variables are supported:
// - [govy.TemplateVars.MinLength]
// - [govy.TemplateVars.MaxLength]
func MapLength[M ~map[K]V, K comparable, V any](minLen, maxLen int) govy.Rule[M] {
enforceMinMaxLength(minLen, maxLen)
tpl := messagetemplates.Get(messagetemplates.LengthTemplate)
return govy.NewRule(func(v M) error {
length := len(v)
if length < minLen || length > maxLen {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
MinLength: minLen,
MaxLength: maxLen,
})
}
return nil
}).
WithErrorCode(ErrorCodeMapLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
MinLength: minLen,
MaxLength: maxLen,
}))
}
// MapMinLength ensures the map's length is greater than or equal to the limit.
func MapMinLength[M ~map[K]V, K comparable, V any](limit int) govy.Rule[M] {
tpl := messagetemplates.Get(messagetemplates.MinLengthTemplate)
return govy.NewRule(func(v M) error {
length := len(v)
if length < limit {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: limit,
})
}
return nil
}).
WithErrorCode(ErrorCodeMapMinLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: limit,
}))
}
// MapMaxLength ensures the map's length is less than or equal to the limit.
func MapMaxLength[M ~map[K]V, K comparable, V any](limit int) govy.Rule[M] {
tpl := messagetemplates.Get(messagetemplates.MaxLengthTemplate)
return govy.NewRule(func(v M) error {
length := len(v)
if length > limit {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: limit,
})
}
return nil
}).
WithErrorCode(ErrorCodeMapMaxLength).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: limit,
}))
}
func enforceMinMaxLength(minLen, maxLen int) {
if minLen > maxLen {
panic(fmt.Sprintf("minLen '%d' is greater than maxLen '%d'", minLen, maxLen))
}
}
package rules
import (
"bytes"
"log/slog"
"text/template"
"github.com/nobl9/govy/internal/logging"
"github.com/nobl9/govy/pkg/govy"
)
func mustExecuteTemplate(tpl *template.Template, vars govy.TemplateVars) string {
var buf bytes.Buffer
if err := tpl.Execute(&buf, vars); err != nil {
logging.Logger().Error("failed to execute message template",
slog.String("template", tpl.Name()),
slog.String("error", err.Error()))
}
return buf.String()
}
package rules
import (
"fmt"
"slices"
"strings"
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/collections"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// OneOf checks if the property's value matches one of the provided values.
// The values must be comparable.
func OneOf[T comparable](values ...T) govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.OneOfTemplate)
return govy.NewRule(func(v T) error {
for i := range values {
if v == values[i] {
return nil
}
}
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
ComparisonValue: values,
})
}).
WithErrorCode(ErrorCodeOneOf).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: values,
}))
}
// OneOfProperties checks if at least one of the properties is set.
// Property is considered set if its value is not empty (non-zero).
func OneOfProperties[S any](getters map[string]func(s S) any) govy.Rule[S] {
tpl := messagetemplates.Get(messagetemplates.OneOfPropertiesTemplate)
return govy.NewRule(func(s S) error {
for _, getter := range getters {
v := getter(s)
if !internal.IsEmpty(v) {
return nil
}
}
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: collections.SortedKeys(getters),
})
}).
WithErrorCode(ErrorCodeOneOfProperties).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: collections.SortedKeys(getters),
}))
}
type mutuallyExclusiveTemplateVars struct {
// NoProperties is set to true if no properties were set and exactly one was required.
NoProperties bool
}
// MutuallyExclusive checks if properties are mutually exclusive.
// This means, exactly one of the properties can be set.
// Property is considered set if its value is not empty (non-zero).
// If required is true, then a single non-empty property is required.
func MutuallyExclusive[S any](required bool, getters map[string]func(s S) any) govy.Rule[S] {
tpl := messagetemplates.Get(messagetemplates.MutuallyExclusiveTemplate)
return govy.NewRule(func(s S) error {
var nonEmpty []string
for name, getter := range getters {
v := getter(s)
if internal.IsEmpty(v) {
continue
}
nonEmpty = append(nonEmpty, name)
}
switch len(nonEmpty) {
case 0:
if !required {
return nil
}
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: collections.SortedKeys(getters),
Custom: mutuallyExclusiveTemplateVars{NoProperties: true},
})
case 1:
return nil
default:
slices.Sort(nonEmpty)
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: nonEmpty,
Custom: mutuallyExclusiveTemplateVars{NoProperties: false},
})
}
}).
WithErrorCode(ErrorCodeMutuallyExclusive).
WithMessageTemplate(tpl).
WithDescription(func() string {
return fmt.Sprintf("properties are mutually exclusive: %s",
strings.Join(collections.SortedKeys(getters), ", "))
}())
}
package rules
import (
"regexp"
"sync"
)
// nolint: lll
// Define all regular expressions here:
var (
// Ref: https://www.ietf.org/rfc/rfc4122.txt
uuidRegexp = lazyRegexCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
asciiRegexp = lazyRegexCompile(`^[\x00-\x7F]*$`)
// Ref: https://www.ietf.org/rfc/rfc1123.txt
rfc1123DnsLabelRegexp = lazyRegexCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
rfc1123DnsSubdomainRegexp = lazyRegexCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
k8sQualifiedNamePartRegexp = lazyRegexCompile(`^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$`)
alphaRegexp = lazyRegexCompile(`^[a-zA-Z]*$`)
alphanumericRegexp = lazyRegexCompile(`^[a-zA-Z0-9]*$`)
alphaUnicodeRegexp = lazyRegexCompile(`^[\p{L}]*$`)
alphanumericUnicodeRegexp = lazyRegexCompile(`^[\p{L}\p{N}]+$`)
fqdnRegexp = lazyRegexCompile(
`^([a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})*?(\.[a-zA-Z]{1}[a-zA-Z0-9]{0,62})\.?$`,
)
)
// lazyRegexCompile returns a function that compiles the regular expression
// once, when the function is called for the first time.
// If the function is never called, the regular expression is never compiled,
// thus saving on performance.
//
// All regular expression literals should be compiled using this function.
//
// Credits: https://github.com/go-playground/validator/commit/2e1df48b5ab876bdd461bdccc51d109389e7572f
func lazyRegexCompile(str string) func() *regexp.Regexp {
var (
regex *regexp.Regexp
once sync.Once
)
return func() *regexp.Regexp {
once.Do(func() {
regex = regexp.MustCompile(str)
})
return regex
}
}
package rules
import (
"github.com/nobl9/govy/internal"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// Required ensures the property's value is not empty (i.e. it's not its type's zero value).
func Required[T any]() govy.Rule[T] {
tpl := messagetemplates.Get(messagetemplates.RequiredTemplate)
return govy.NewRule(func(v T) error {
if internal.IsEmpty(v) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
})
}
return nil
}).
WithErrorCode(ErrorCodeRequired).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
package rules
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// StringNotEmpty ensures the property's value is not empty.
// The string is considered empty if it contains only whitespace characters.
func StringNotEmpty() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringNonEmptyTemplate)
return govy.NewRule(func(s string) error {
if len(strings.TrimSpace(s)) == 0 {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringNotEmpty).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringMatchRegexp ensures the property's value matches the regular expression.
// The error message can be enhanced with examples of valid values.
func StringMatchRegexp(re *regexp.Regexp) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringMatchRegexpTemplate)
return govy.NewRule(func(s string) error {
if !re.MatchString(s) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: re.String(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringMatchRegexp).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: re.String(),
}))
}
// StringDenyRegexp ensures the property's value does not match the regular expression.
// The error message can be enhanced with examples of invalid values.
func StringDenyRegexp(re *regexp.Regexp) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringDenyRegexpTemplate)
return govy.NewRule(func(s string) error {
if re.MatchString(s) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: re.String(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringDenyRegexp).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: re.String(),
}))
}
// StringDNSLabel ensures the property's value is a valid DNS label as defined by [RFC 1123].
//
// [RFC 1123]: https://www.ietf.org/rfc/rfc1123.txt
func StringDNSLabel() govy.RuleSet[string] {
return govy.NewRuleSet(
StringLength(1, 63),
StringMatchRegexp(rfc1123DnsLabelRegexp()).
WithDetails("an RFC-1123 compliant label name must consist of lower case alphanumeric characters or '-',"+
" and must start and end with an alphanumeric character").
WithExamples("my-name", "123-abc"),
).
WithErrorCode(ErrorCodeStringDNSLabel).
Cascade(govy.CascadeModeStop)
}
// StringDNSSubdomain ensures the property's value is a valid DNS subdomain as defined by [RFC 1123].
//
// [RFC 1123]: https://www.ietf.org/rfc/rfc1123.txt
func StringDNSSubdomain() govy.RuleSet[string] {
return govy.NewRuleSet(
StringLength(1, 253),
StringMatchRegexp(rfc1123DnsSubdomainRegexp()).
WithDetails("an RFC-1123 compliant subdomain must consist of lower case alphanumeric characters, '-'"+
" or '.', and must start and end with an alphanumeric character").
WithExamples("example.com"),
).
WithErrorCode(ErrorCodeStringDNSSubdomain).
Cascade(govy.CascadeModeStop)
}
// StringEmail ensures the property's value is a valid email address.
// It follows [RFC 5322] specification which is more permissive in regards to domain names.
//
// [RFC 5322]: https://www.ietf.org/rfc/rfc5322.txt
func StringEmail() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringEmailTemplate)
return govy.NewRule(func(s string) error {
if _, err := mail.ParseAddress(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringEmail).
WithMessageTemplate(tpl).
WithDescription("string must be a valid email address")
}
// StringURL ensures property's value is a valid URL as defined by [url.Parse] function.
// Unlike [URL] it does not impose any additional rules upon parsed [url.URL].
func StringURL() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.URLTemplate)
return govy.NewRule(func(s string) error {
u, err := url.Parse(s)
if err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: "failed to parse URL: " + err.Error(),
})
}
if err = validateURL(u); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringURL).
WithMessageTemplate(tpl).
WithDescription(urlDescription)
}
// StringMAC ensures property's value is a valid MAC address.
func StringMAC() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringMACTemplate)
return govy.NewRule(func(s string) error {
if _, err := net.ParseMAC(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringMAC).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringIP ensures property's value is a valid IP address.
func StringIP() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringIPTemplate)
return govy.NewRule(func(s string) error {
if ip := net.ParseIP(s); ip == nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringIP).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringIPv4 ensures property's value is a valid IPv4 address.
func StringIPv4() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringIPv4Template)
return govy.NewRule(func(s string) error {
if ip := net.ParseIP(s); ip == nil || ip.To4() == nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringIPv4).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringIPv6 ensures property's value is a valid IPv6 address.
func StringIPv6() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringIPv6Template)
return govy.NewRule(func(s string) error {
if ip := net.ParseIP(s); ip == nil || ip.To4() != nil || len(ip) != net.IPv6len {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringIPv6).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringCIDR ensures property's value is a valid CIDR notation IP address.
func StringCIDR() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringCIDRTemplate)
return govy.NewRule(func(s string) error {
if _, _, err := net.ParseCIDR(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringCIDR).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringCIDRv4 ensures property's value is a valid CIDR notation IPv4 address.
func StringCIDRv4() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringCIDRv4Template)
return govy.NewRule(func(s string) error {
if ip, ipNet, err := net.ParseCIDR(s); err != nil || ip.To4() == nil || !ipNet.IP.Equal(ip) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringCIDRv4).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringCIDRv6 ensures property's value is a valid CIDR notation IPv6 address.
func StringCIDRv6() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringCIDRv6Template)
return govy.NewRule(func(s string) error {
if ip, _, err := net.ParseCIDR(s); err != nil || ip.To4() != nil || len(ip) != net.IPv6len {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringCIDRv6).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringUUID ensures property's value is a valid UUID string as defined by [RFC 4122].
// It does not enforce a specific UUID version.
//
// [RFC 4122]: https://www.ietf.org/rfc/rfc4122.txt
func StringUUID() govy.Rule[string] {
return StringMatchRegexp(uuidRegexp()).
WithDetails("expected RFC-4122 compliant UUID string").
WithExamples(
"00000000-0000-0000-0000-000000000000",
"e190c630-8873-11ee-b9d1-0242ac120002",
"79258D24-01A7-47E5-ACBB-7E762DE52298",
).
WithErrorCode(ErrorCodeStringUUID)
}
// StringASCII ensures property's value contains only ASCII characters.
func StringASCII() govy.Rule[string] {
return StringMatchRegexp(asciiRegexp()).WithErrorCode(ErrorCodeStringASCII)
}
// StringJSON ensures property's value is a valid JSON literal.
func StringJSON() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringJSONTemplate)
return govy.NewRule(func(s string) error {
if !json.Valid([]byte(s)) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringJSON).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringContains ensures the property's value contains all the provided substrings.
func StringContains(substrings ...string) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringContainsTemplate)
return govy.NewRule(func(s string) error {
matched := true
for _, substr := range substrings {
if !strings.Contains(s, substr) {
matched = false
break
}
}
if !matched {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: substrings,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringContains).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: substrings,
}))
}
// StringExcludes ensures the property's value does not contain any of the provided substrings.
func StringExcludes(substrings ...string) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringExcludesTemplate)
return govy.NewRule(func(s string) error {
for _, substr := range substrings {
if strings.Contains(s, substr) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: substrings,
})
}
}
return nil
}).
WithErrorCode(ErrorCodeStringExcludes).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: substrings,
}))
}
// StringStartsWith ensures the property's value starts with one of the provided prefixes.
func StringStartsWith(prefixes ...string) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringStartsWithTemplate)
return govy.NewRule(func(s string) error {
matched := false
for _, prefix := range prefixes {
if strings.HasPrefix(s, prefix) {
matched = true
break
}
}
if !matched {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: prefixes,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringStartsWith).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: prefixes,
}))
}
// StringEndsWith ensures the property's value ends with one of the provided suffixes.
func StringEndsWith(suffixes ...string) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringEndsWithTemplate)
return govy.NewRule(func(s string) error {
matched := false
for _, suffix := range suffixes {
if strings.HasSuffix(s, suffix) {
matched = true
break
}
}
if !matched {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: suffixes,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringEndsWith).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: suffixes,
}))
}
// StringTitle ensures each word in a string starts with a capital letter.
func StringTitle() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringTitleTemplate)
return govy.NewRule(func(s string) error {
if len(s) == 0 {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
prev := ' '
for _, r := range s {
if isStringSeparator(prev) {
if !unicode.IsUpper(r) && !isStringSeparator(r) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
}
prev = r
}
return nil
}).
WithErrorCode(ErrorCodeStringTitle).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
type stringGitRefTemplateVars struct {
GitRefEmpty bool
GitRefEndsWithDot bool
GitRefAtLeastOneSlash bool
GitRefEmptyPart bool
GitRefStartsWithDash bool
GitRefForbiddenChars bool
}
// StringGitRef ensures a git reference name follows the [git-check-ref-format] rules.
//
// It is important to note that this function does not check if the reference exists in the repository.
// It only checks if the reference name is valid.
// This functions does not support the '--refspec-pattern', '--normalize', and '--allow-onelevel' options.
//
// Git imposes the following rules on how references are named:
//
// 1. They can include slash '/' for hierarchical (directory) grouping, but no
// slash-separated component can begin with a dot '.' or end with the
// sequence '.lock'.
// 2. They must contain at least one '/'. This enforces the presence of a
// category (e.g. 'heads/', 'tags/'), but the actual names are not restricted.
// 3. They cannot have ASCII control characters (i.e. bytes whose values are
// lower than '\040', or '\177' DEL).
// 4. They cannot have '?', '*', '[', ' ', '~', '^', ', '\t', '\n', '@{', '\\' and '..',
// 5. They cannot begin or end with a slash '/'.
// 6. They cannot end with a '.'.
// 7. They cannot be the single character '@'.
// 8. 'HEAD' is an allowed special name.
//
// Slightly modified version of [go-git] implementation, kudos to the authors!
//
// [git-check-ref-format] :https://git-scm.com/docs/git-check-ref-format
// [go-git]: https://github.com/go-git/go-git/blob/95afe7e1cdf71c59ee8a71971fac71880020a744/plumbing/reference.go#L167
func StringGitRef() govy.Rule[string] {
type tplVars = stringGitRefTemplateVars
tpl := messagetemplates.Get(messagetemplates.StringGitRefTemplate)
return govy.NewRule(func(s string) error {
if len(s) == 0 {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{GitRefEmpty: true},
})
}
if s == "HEAD" {
return nil
}
if strings.HasSuffix(s, ".") {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{GitRefEndsWithDot: true},
})
}
parts := strings.Split(s, "/")
if len(parts) < 2 {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{GitRefAtLeastOneSlash: true},
})
}
isBranch := strings.HasPrefix(s, "refs/heads/")
isTag := strings.HasPrefix(s, "refs/tags/")
for _, part := range parts {
if len(part) == 0 {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{GitRefEmptyPart: true},
})
}
if (isBranch || isTag) && strings.HasPrefix(part, "-") {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{GitRefStartsWithDash: true},
})
}
if part == "@" ||
strings.HasPrefix(part, ".") ||
strings.HasSuffix(part, ".lock") ||
stringContainsGitRefForbiddenChars(part) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{GitRefForbiddenChars: true},
})
}
}
return nil
}).
WithErrorCode(ErrorCodeStringGitRef).
WithMessageTemplate(tpl).
WithDetails("see https://git-scm.com/docs/git-check-ref-format for more information on Git reference naming rules").
WithDescription("string must be a valid git reference")
}
// StringFileSystemPath ensures the property's value is an existing file system path.
func StringFileSystemPath() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringFileSystemPathTemplate)
return govy.NewRule(func(s string) error {
if _, err := osStatFile(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: handleFilePathError(err).Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringFileSystemPath).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringFilePath ensures the property's value is a file system path pointing to an existing file.
func StringFilePath() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringFilePathTemplate)
return govy.NewRule(func(s string) error {
info, err := osStatFile(s)
if err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: handleFilePathError(err).Error(),
})
}
if info.IsDir() {
return errFilePathNotFile
}
return nil
}).
WithErrorCode(ErrorCodeStringFilePath).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringDirPath ensures the property's value is a file system path pointing to an existing directory.
func StringDirPath() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringDirPathTemplate)
return govy.NewRule(func(s string) error {
info, err := osStatFile(s)
if err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: handleFilePathError(err).Error(),
})
}
if !info.IsDir() {
return errFilePathNotDir
}
return nil
}).
WithErrorCode(ErrorCodeStringDirPath).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringMatchFileSystemPath ensures the property's value matches the provided file path pattern.
// It uses [filepath.Match] to match the pattern. The native function comes with some limitations,
// most notably it does not support '**' recursive expansion.
// It does not check if the file path exists on the file system.
func StringMatchFileSystemPath(pattern string) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringMatchFileSystemPathTemplate)
return govy.NewRule(func(s string) error {
ok, err := filepath.Match(pattern, s)
if err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: pattern,
Error: err.Error(),
})
}
if !ok {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: pattern,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringMatchFileSystemPath).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: pattern,
}))
}
// StringRegexp ensures the property's value is a valid regular expression.
// The accepted regular expression syntax must comply to RE2.
// It is described at https://golang.org/s/re2syntax, except for \C.
// For an overview of the syntax, see [regexp/syntax] package.
//
// [regexp/syntax]: https://pkg.go.dev/regexp/syntax
func StringRegexp() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringRegexpTemplate)
return govy.NewRule(func(s string) error {
if _, err := regexp.Compile(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringRegexp).
WithMessageTemplate(tpl).
// nolint: lll
WithDetails(`the regular expression syntax must comply to RE2, it is described at https://golang.org/s/re2syntax, except for \C; for an overview of the syntax, see https://pkg.go.dev/regexp/syntax`).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringCrontab ensures the property's value is a valid crontab schedule expression.
// For more details on cron expressions read [crontab manual] and visit [crontab.guru].
//
// [crontab manual]: https://www.man7.org/linux/man-pages/man5/crontab.5.html
// [crontab.guru]: https://crontab.guru
func StringCrontab() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringCrontabTemplate)
return govy.NewRule(func(s string) error {
if err := parseCrontab(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringCrontab).
WithMessageTemplate(tpl).
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringDateTime ensures the property's value is a valid date and time in the specified layout.
//
// The layout must be a valid time format string as defined by [time.Parse],
// an example of which is [time.RFC3339].
func StringDateTime(layout string) govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringDateTimeTemplate)
return govy.NewRule(func(s string) error {
if _, err := time.Parse(layout, s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
ComparisonValue: layout,
})
}
return nil
}).
WithErrorCode(ErrorCodeStringDateTime).
WithMessageTemplate(tpl).
WithDetails("date and time format follows Go's time layout, see https://pkg.go.dev/time#Layout for more details").
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{
ComparisonValue: layout,
}))
}
// StringTimeZone ensures the property's value is a valid time zone name which
// uniquely identifies a time zone in the IANA Time Zone database.
// Example: "America/New_York", "Europe/London".
//
// Under the hood [time.LoadLocation] is called to parse the zone.
// The native function allows empty string and 'Local' keyword to be supplied.
// However, these two options are explicitly forbidden by [StringTimeZone].
//
// Furthermore, the time zone data is not readily available in one predefined place.
// [time.LoadLocation] looks for the IANA Time Zone database in specific places,
// please refer to its documentation for more information.
func StringTimeZone() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringTimeZoneTemplate)
return govy.NewRule(func(s string) error {
if s == "" || s == "Local" {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
})
}
if _, err := time.LoadLocation(s); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeStringTimeZone).
WithMessageTemplate(tpl).
WithExamples("UTC", "America/New_York", "Europe/Warsaw").
WithDescription(mustExecuteTemplate(tpl, govy.TemplateVars{}))
}
// StringAlpha ensures the property's value consists only of ASCII letters.
func StringAlpha() govy.Rule[string] {
return StringMatchRegexp(alphaRegexp()).
WithErrorCode(ErrorCodeStringAlpha)
}
// StringAlphanumeric ensures the property's value consists only of ASCII letters and numbers.
func StringAlphanumeric() govy.Rule[string] {
return StringMatchRegexp(alphanumericRegexp()).
WithErrorCode(ErrorCodeStringAlphanumeric)
}
// StringAlphaUnicode ensures the property's value consists only of Unicode letters.
func StringAlphaUnicode() govy.Rule[string] {
return StringMatchRegexp(alphaUnicodeRegexp()).
WithErrorCode(ErrorCodeStringAlphaUnicode)
}
// StringAlphanumericUnicode ensures the property's value consists only of Unicode letters and numbers.
func StringAlphanumericUnicode() govy.Rule[string] {
return StringMatchRegexp(alphanumericUnicodeRegexp()).
WithErrorCode(ErrorCodeStringAlphanumericUnicode)
}
// StringFQDN ensures the property's value is a fully qualified domain name (FQDN).
func StringFQDN() govy.Rule[string] {
return StringMatchRegexp(fqdnRegexp()).
WithErrorCode(ErrorCodeStringFQDN)
}
type stringKubernetesQualifiedNameTemplateVars struct {
EmptyPrefixPart bool
PrefixLength bool
PrefixRegexp bool
TooManyParts bool
EmptyNamePart bool
NamePartLength bool
NamePartRegexp bool
}
const (
maxK8sSubdomainPrefixPartLength = 253
maxK8sQualifiedNamePartLength = 63
)
// StringKubernetesQualifiedName ensures the property's value is a valid "qualified name"
// as defined by [Kubernetes validation].
// The qualified name is used in various parts of the Kubernetes system, examples:
// - annotation names
// - label names
//
// [Kubernetes validation]: https://github.com/kubernetes/kubernetes/blob/55573a0739785292e62b32a748c0b0735ff963ba/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L41
func StringKubernetesQualifiedName() govy.RuleSet[string] {
return govy.NewRuleSet(
StringLength(1, maxK8sSubdomainPrefixPartLength+1+maxK8sQualifiedNamePartLength),
stringKubernetesQualifiedNameRule(),
).
Cascade(govy.CascadeModeStop).
WithErrorCode(ErrorCodeStringKubernetesQualifiedName)
}
func stringKubernetesQualifiedNameRule() govy.Rule[string] {
type tplVars = stringKubernetesQualifiedNameTemplateVars
tpl := messagetemplates.Get(messagetemplates.StringKubernetesQualifiedNameTemplate)
return govy.NewRule(func(s string) error {
parts := strings.Split(s, "/")
var name string
switch len(parts) {
case 1:
name = parts[0]
case 2:
var prefix string
prefix, name = parts[0], parts[1]
switch {
case len(prefix) == 0:
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{EmptyPrefixPart: true},
})
case len(prefix) > maxK8sSubdomainPrefixPartLength:
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: maxK8sSubdomainPrefixPartLength,
Custom: tplVars{PrefixLength: true},
})
case !rfc1123DnsSubdomainRegexp().MatchString(prefix):
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: rfc1123DnsSubdomainRegexp().String(),
Custom: tplVars{PrefixRegexp: true},
})
}
default:
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{TooManyParts: true},
})
}
switch {
case len(name) == 0:
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
Custom: tplVars{EmptyNamePart: true},
})
case len(name) > maxK8sQualifiedNamePartLength:
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: maxK8sQualifiedNamePartLength,
Custom: tplVars{NamePartLength: true},
})
case !k8sQualifiedNamePartRegexp().MatchString(name):
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: s,
ComparisonValue: k8sQualifiedNamePartRegexp().String(),
Custom: tplVars{NamePartRegexp: true},
})
}
return nil
}).
WithMessageTemplate(tpl).
WithDetails("Kubernetes Qualified Name must consist of alphanumeric characters, '-', '_' or '.', "+
"and must start and end with an alphanumeric character with an optional DNS subdomain prefix and '/'").
WithExamples("my.domain/MyName", "MyName", "my.name", "123-abc").
WithDescription("string must be a Kubernetes Qualified Name")
}
// isStringSeparator is directly copied from [strings] package.
func isStringSeparator(r rune) bool {
// ASCII alphanumerics and underscore are not separators
if r <= 0x7F {
switch {
case '0' <= r && r <= '9':
return false
case 'a' <= r && r <= 'z':
return false
case 'A' <= r && r <= 'Z':
return false
case r == '_':
return false
}
return true
}
// Letters and digits are not separators
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return false
}
// Otherwise, all we can do for now is treat spaces as separators.
return unicode.IsSpace(r)
}
var gitRefDisallowedStrings = map[rune]struct{}{
'\\': {}, '?': {}, '*': {}, '[': {}, ' ': {}, '~': {}, '^': {}, ':': {}, '\t': {}, '\n': {},
}
// stringContainsGitRefForbiddenChars is a brute force method to check if a string contains
// any of the Git reference forbidden characters.
func stringContainsGitRefForbiddenChars(s string) bool {
for i, c := range s {
if c == '\177' || (c >= '\000' && c <= '\037') {
return true
}
// Check for '..' and '@{'.
if c == '.' && i < len(s)-1 && s[i+1] == '.' ||
c == '@' && i < len(s)-1 && s[i+1] == '{' {
return true
}
if _, ok := gitRefDisallowedStrings[c]; !ok {
continue
}
return true
}
return false
}
func osStatFile(path string) (os.FileInfo, error) {
if strings.TrimSpace(path) == "" {
return nil, errFilePathEmpty
}
hasSeparatorSuffix := strings.HasSuffix(path, string(filepath.Separator))
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get user home directory: %w", err)
}
path = home + string(filepath.Separator) + path[1:]
}
path = filepath.Clean(path)
// If the path ends with a separator, we need to add it back after cleaning.
if hasSeparatorSuffix {
path += string(filepath.Separator)
}
return os.Stat(path)
}
var (
errFilePathNotExists = errors.New("path does not exist")
errFilePathNoPerm = errors.New("permission to inspect path denied")
errFilePathEmpty = errors.New("path does not exist")
errFilePathNotFile = errors.New("path must point to a file and not to a directory")
errFilePathNotDir = errors.New("path must point to a directory and not to a file")
)
func handleFilePathError(err error) error {
var pathErr *os.PathError
if !errors.As(err, &pathErr) {
return err
}
if errors.Is(err, os.ErrNotExist) {
return errFilePathNotExists
}
if errors.Is(err, os.ErrPermission) {
return errFilePathNoPerm
}
return err
}
package rules
import (
"strings"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// HashFunction accepts a value and returns a comparable hash.
type HashFunction[V any, H comparable] func(v V) H
// HashFuncSelf returns a HashFunction which returns its input value as a hash itself.
// The value must be comparable.
func HashFuncSelf[H comparable]() HashFunction[H, H] {
return func(v H) H { return v }
}
type sliceUniqueTemplateVars struct {
Constraints []string
FirstOrdinal string
SecondOrdinal string
}
// SliceUnique ensures that a slice contains unique elements based on a provided HashFunction.
// You can optionally specify constraints which will be included in the error message to further
// clarify the reason for breaking uniqueness.
func SliceUnique[S []V, V any, H comparable](hashFunc HashFunction[V, H], constraints ...string) govy.Rule[S] {
tpl := messagetemplates.Get(messagetemplates.SliceUniqueTemplate)
return govy.NewRule(func(slice S) error {
unique := make(map[H]int)
for i := range slice {
hash := hashFunc(slice[i])
if j, ok := unique[hash]; ok {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: slice,
Custom: sliceUniqueTemplateVars{
Constraints: constraints,
FirstOrdinal: ordinalString(j + 1),
SecondOrdinal: ordinalString(i + 1),
},
})
}
unique[hash] = i
}
return nil
}).
WithErrorCode(ErrorCodeSliceUnique).
WithMessageTemplate(tpl).
WithDescription(func() string {
msg := "elements must be unique"
if len(constraints) > 0 {
msg += " according to the following constraints: " + strings.Join(constraints, ", ")
}
return msg
}())
}
package rules
import (
"errors"
"net/url"
"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)
// URL ensures the URL is valid.
// The URL must have a scheme (e.g. https://) and contain either host, fragment or opaque data.
func URL() govy.Rule[*url.URL] {
tpl := messagetemplates.Get(messagetemplates.URLTemplate)
return govy.NewRule(func(v *url.URL) error {
if err := validateURL(v); err != nil {
return govy.NewRuleErrorTemplate(govy.TemplateVars{
PropertyValue: v,
Error: err.Error(),
})
}
return nil
}).
WithErrorCode(ErrorCodeURL).
WithMessageTemplate(tpl).
WithDescription(urlDescription)
}
const urlDescription = "valid URL must have a scheme (e.g. https://) and contain either host, fragment or opaque data"
func validateURL(u *url.URL) error {
if u.Scheme == "" {
return errors.New("valid URL must have a scheme (e.g. https://)")
}
if u.Host == "" && u.Fragment == "" && u.Opaque == "" {
return errors.New("valid URL must contain either host, fragment or opaque data")
}
return nil
}