diff --git a/README.md b/README.md index b1d2892..730d6cd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # cli-validation -Library to help with flag validation of urfave/cli +Library with additional validators for flag validation feature of `urfave/cli` (current for v3 only). Validation in `urfave/cli` is run only after the value has been converted from a string to the flag value type. So these validators need to focus only on validating the value, whether it be within a certain range or picked from a fixed number of values. Validation cannot be used to check slice/map length, because len can be known only after all the flags have been parsed. That kind of validation is a candidate for a Flag Action since those actions are run after all flags have been parsed \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d0213a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/urfave/cli-validation + +go 1.19 + +require github.com/stretchr/testify v1.8.4 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa4b6e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..5cde359 --- /dev/null +++ b/validation.go @@ -0,0 +1,124 @@ +package validation + +import ( + "fmt" + "regexp" +) + +type Signed interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 +} + +type Unsigned interface { + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr +} + +type Integer interface { + Signed | Unsigned +} + +type Float interface { + ~float32 | ~float64 +} + +type Ordered interface { + Integer | Float +} + +// ConditionOrError ir a helper function to make writing +// validation functions much easier +func ConditionOrError(cond bool, err error) error { + if cond { + return nil + } + return err +} + +// ValidationChainAll allows one to chain a sequence of validation +// functions to construct a single validation function. All the +// individual validations must pass for the validation to succeed +func ValidationChainAll[T any](fns ...func(T) error) func(T) error { + return func(v T) error { + for _, fn := range fns { + if err := fn(v); err != nil { + return err + } + } + return nil + } +} + +// ValidationChainAny allows one to chain a sequence of validation +// functions to construct a single validation function. Atleast one +// of the individual validations must pass for the validation to succeed +func ValidationChainAny[T any](fns ...func(T) error) func(T) error { + return func(v T) error { + var errs []error + for _, fn := range fns { + if err := fn(v); err == nil { + return nil + } else { + errs = append(errs, err) + } + } + return fmt.Errorf("%+v", errs) + } +} + +// Min means that the value to be checked needs to be atleast(and including) +// the checked value +func Min[T Ordered](c T) func(T) error { + return func(v T) error { + return ConditionOrError(v >= c, fmt.Errorf("%v is not less than %v", v, c)) + } +} + +// Max means that the value to be checked needs to be atmost(and including) +// the checked value +func Max[T Ordered](c T) func(T) error { + return func(v T) error { + return ConditionOrError(v <= c, fmt.Errorf("%v is not greater than %v", v, c)) + } +} + +// Max means that the value to be checked needs to be atmost(and including) +// the checked value +func RangeInclusive[T Ordered](a, b T) func(T) error { + return ValidationChainAll[T](Min[T](a), Max[T](b)) +} + +// Enum lets the given value be checked against a given set of values +func Enum[T comparable](values ...T) func(T) error { + return func(v T) error { + for _, value := range values { + if value == v { + return nil + } + } + return fmt.Errorf("%v not in %+v", v, values) + } +} + +// Regex allows for pattern matching on string value fields +func Regex[T ~string](pattern string) func(T) error { + return func(v T) error { + if r, err := regexp.Compile(pattern); err != nil { + return err + } else if !r.Match([]byte(v)) { + return fmt.Errorf("%v is not of pattern %s", v, pattern) + } + return nil + } +} + +// SliceValidator allows using a simple type validator with a slice +func SliceValidator[T any](f func(T) error) func([]T) error { + return func(values []T) error { + for i, v := range values { + if err := f(v); err != nil { + return fmt.Errorf("value at slice[%d] : %w", i, err) + } + } + return nil + } +} diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..eb51af1 --- /dev/null +++ b/validation_test.go @@ -0,0 +1,236 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type testcase[T any] struct { + name string + f func(T) error + input T + errExpected bool +} + +func testWithInput[T any](t *testing.T, tcases []testcase[T]) { + r := require.New(t) + for _, tc := range tcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.f(tc.input) + if tc.errExpected { + r.Error(err) + } else { + r.NoError(err) + } + }) + } +} +func TestMin(t *testing.T) { + + inputs := []testcase[int]{ + { + name: "min lower limit", + f: Min[int](10), + input: 10, + }, + { + name: "min normal", + f: Min[int](10), + input: 11, + }, + { + name: "min failure", + f: Min[int](10), + input: 8, + errExpected: true, + }, + } + + testWithInput[int](t, inputs) +} +func TestMax(t *testing.T) { + + inputs := []testcase[float64]{ + { + name: "max upper limit", + f: Max[float64](10.0), + input: 10.0, + }, + { + name: "max normal", + f: Max[float64](10.0), + input: 9.0, + }, + { + name: "max failure", + f: Max[float64](10.0), + input: 10.00001, + errExpected: true, + }, + } + + testWithInput[float64](t, inputs) +} +func TestRange(t *testing.T) { + + inputs := []testcase[uint64]{ + { + name: "lower limit", + f: RangeInclusive[uint64](10, 16), + input: 10, + }, + { + name: "upper limit", + f: RangeInclusive[uint64](10, 16), + input: 16, + }, + { + name: "lower limit failure", + f: RangeInclusive[uint64](10, 16), + input: 9, + errExpected: true, + }, + { + name: "upper limit failure", + f: RangeInclusive[uint64](10, 16), + input: 17, + errExpected: true, + }, + { + name: "normal", + f: RangeInclusive[uint64](10, 16), + input: 12, + }, + } + + testWithInput[uint64](t, inputs) +} +func TestDisjointRange(t *testing.T) { + + inputs := []testcase[int16]{ + { + name: "lower limit lower range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 10, + }, + { + name: "upper limit lower range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 16, + }, + { + name: "lower limit upper range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 56, + }, + { + name: "upper limit upper range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 67, + }, + { + name: "normal lower range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 13, + }, + { + name: "normal upper range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 60, + }, + { + name: "failure below lower range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 1, + errExpected: true, + }, + { + name: "failure in between lower and upper range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 20, + errExpected: true, + }, + { + name: "failure above upper range", + f: ValidationChainAny[int16](RangeInclusive[int16](10, 16), RangeInclusive[int16](56, 67)), + input: 70, + errExpected: true, + }, + } + + testWithInput[int16](t, inputs) +} +func TestEnum(t *testing.T) { + + inputs := []testcase[string]{ + { + name: "valid value for enum - 1", + f: Enum[string]("hello", "bar", "foo"), + input: "hello", + }, + { + name: "valid value for enum - 2", + f: Enum[string]("hello", "bar", "foo"), + input: "bar", + }, + { + name: "invalid value for enum", + f: Enum[string]("hello", "bar", "foo"), + input: "helloee", + errExpected: true, + }, + } + + testWithInput[string](t, inputs) +} + +func TestRegex(t *testing.T) { + type myString string + + inputs := []testcase[myString]{ + { + name: "valid value regex match - 1", + f: Regex[myString]("foo[1-7].*y"), + input: "foo1y", + }, + { + name: "valid value regex match - 2", + f: Regex[myString]("foo[1-7].*y"), + input: "foo1ttthsyy", + }, + { + name: "invalid value regex match - 1", + f: Regex[myString]("foo[1-7].*y"), + input: "fooy", + errExpected: true, + }, + { + name: "invalid value regex match - 2", + f: Regex[myString]("foo[1-7].*y"), + input: "fooOy", + errExpected: true, + }, + } + + testWithInput[myString](t, inputs) +} + +func TestSliceValidator(t *testing.T) { + + inputs := []testcase[[]uint32]{ + { + name: "valid values slices", + f: SliceValidator[uint32](Min[uint32](7)), + input: []uint32{9, 10, 18, 14}, + }, + { + name: "invalid value slice - 1", + f: SliceValidator[uint32](Min[uint32](7)), + input: []uint32{9, 10, 6, 14}, + errExpected: true, + }, + } + + testWithInput[[]uint32](t, inputs) +}