Skip to content

Commit

Permalink
move validation code
Browse files Browse the repository at this point in the history
  • Loading branch information
nieomylnieja committed Aug 2, 2024
1 parent 89a2ca6 commit c18a23d
Show file tree
Hide file tree
Showing 53 changed files with 5,676 additions and 0 deletions.
180 changes: 180 additions & 0 deletions pkg/govy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Validation

Package validation implements a functional API for consistent,
type safe validation.
It puts heavy focus on end user errors readability,
providing means of crafting clear and information-rich error messages.

Validation pipeline is immutable and lazily loaded.

- Immutable, as changing the pipeline through chained functions,
will return a new pipeline.
It allows extended reusability of validation components.
- Lazily loaded, as properties are extracted through getter functions,
which are only called when you call the `Validate` method.
Functional approach allows validation components to only be called when
needed.
You should define your pipeline once and call it
whenever you validate instances of your entity.

All that has been made possible by the introduction of generics in Go.
Prior to that, there wasn't really any viable way to create type safe
validation API.
Although the current state of type inference is somewhat clunky,
the API can only improve in time when generics support in Go is further
extended.

## NOTE: Work in progress

Although already battle tested through SLO hellfire,
this library is still a work in progress.
The principles and the API at its core won't change,
but the details and capabilities might hopefully will.
Contributions and suggestions are most welcome!

## Usage

**This README goes through an abstract overview of the library. \
Refer to [example_test.go](./example_test.go)
for a hands-on tutorial with runnable examples.**

### Legend

- [Validator](#validator)
- [Property rules](#property-rules)
- [PropertyRules](#propertyrules) _(single property)_
- [PropertyRulesForEach](#propertyrulesforeach) _(slice of properties)_
- [Rule](#rule)
- [SingleRule](#singlerule)
- [RuleSet](#ruleset)
- [Errors](#errors)
- [ValidatorError](#validatorerror)
- [PropertyError](#propertyerror)
- [RuleError](#ruleerror)
- [FAQ](#faq)

### Validator

Validator aggregates [property rules](#property-rules) into a single validation scenario,
most commonly associated with an entity, like `struct`.

If any property rules fail [ValidatorError](#validatorerror) is returned.

### Property rules

When validating structured data, namely `struct`,
each structure consists of multiple properties.
For `struct`, these will be its fields.

Most commonly, property has its name and value.
Property name should be derived from the struct
representation visible by the errors consumer,
this will most likely be JSON format.

Nested properties are represented by paths,
where each property is delimited by `.`.
Arrays are represented by `[<index>]`.
Let's examine a simple teacher/student example:

```go
package university

type Teacher struct {
Name string `json:"name"`
Students []Student `json:"students"`
}

type Student struct {
Index string
}
```

We can distinguish the following property paths:

- `name`
- `students`
- `students[0].Index` _(let's assume there's only a single student)_

If any property rule fails [PropertyError](#propertyerror) is returned.

#### PropertyRules

`PropertyRules` aggregates [rules](#rule) for a single property.

#### PropertyRulesForEach

`PropertyRulesForEach` is an extension of [PropertyRules](#propertyrules),
it provides means of defining rules for each property in a slice.

Currently, it only works with slices, maps are not supported.

### Rule

Rules validate a concrete value.
If a rule is not met it returns [RuleError](#ruleerror).

#### SingleRule

This is the most basic validation building block.
Its error code can be set using `WithErrorCode` function and its error message can
also be enhanced using `WithDetails` function.
Details are delimited by `;` character.

#### RuleSet

Rule sets are used to aggregate multiple [SingleRule](#singlerule)
into a single validation rule.
It wraps any and all errors returned from single rules in a container which is later
on unpacked. If you use either `WithErrorCode` or `WithDetails` functions, each error
will be extended with the provided details and error code.

### Errors

Each validation level defines an error which adds further details of what went wrong.

#### ValidatorError

Adds top level entity name, following our teacher example,
it would be simply `teacher`.
Although that once again depends on how your end use perceives this entity.
It wraps multiple [PropertyError](#propertyerror).

#### PropertyError

Adds both property name and value. Property value is converted to a string
representation. It wraps multiple [RuleError](#ruleerror).

#### RuleError

The most basic building block for validation errors, associated with a single
failing [SingleRule](#singlerule).
It conveys an error message and [ErrorCode](#error-codes).

#### Error codes

To aid the process of testing, `ErrorCode` has been introduced along
with a helper functions `WithErrorCode` to associate [Rule](#rule) with an error
code and `AddCode` to associate multiple error codes with a single [Rule](#rule).
Multiple error codes are delimited by `:`,
similar to how wrapped errors are represented in Go.

To check if `ErrorCode` is part if a given validation error, use `HasErrorCode`.

## FAQ

### Why not use existing validation library?

Existing, established solutions are mostly based on struct tags and heavily
utilize reflection.
This leaves type safety an issue to be solved and handled by developers.
For simple use cases, covered by predefined validation functions,
this solutions works well enough.
However when adding custom validation rules,
type casting has to be heavily utilized,
and it becomes increasingly harder to track what exactly is being validated.
Another issue is the readability of the errors,
it's often hard or even impossible to shape the error to the developer liking.

### Acknowledgements

Heavily inspired by [C# FluentValidation](https://docs.fluentvalidation.net/).
11 changes: 11 additions & 0 deletions pkg/govy/cascade_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package validation

// CascadeMode defines how validation should behave when an error is encountered.
type CascadeMode uint

const (
// CascadeModeContinue will continue validation after first error.
CascadeModeContinue CascadeMode = iota
// CascadeModeStop will stop validation on first error encountered.
CascadeModeStop
)
108 changes: 108 additions & 0 deletions pkg/govy/comparable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package validation

import (
"fmt"

"github.com/pkg/errors"

Check failure on line 6 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run Golang vulnerability check

no required module provides package github.com/pkg/errors; to add it:

Check failure on line 6 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run Golang vulnerability check

could not import github.com/pkg/errors (invalid package name: "")

Check failure on line 6 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run unit tests

no required module provides package github.com/pkg/errors; to add it:
"golang.org/x/exp/constraints"

Check failure on line 7 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run Golang vulnerability check

no required module provides package golang.org/x/exp/constraints; to add it:

Check failure on line 7 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run Golang vulnerability check

could not import golang.org/x/exp/constraints (invalid package name: "")

Check failure on line 7 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run unit tests

no required module provides package golang.org/x/exp/constraints; to add it:
)

func EqualTo[T comparable](compared T) SingleRule[T] {
msg := fmt.Sprintf(comparisonFmt, cmpEqualTo, compared)
return NewSingleRule(func(v T) error {
if v != compared {
return errors.New(msg)
}
return nil
}).
WithErrorCode(ErrorCodeEqualTo).
WithDescription(msg)
}

func NotEqualTo[T comparable](compared T) SingleRule[T] {
msg := fmt.Sprintf(comparisonFmt, cmpNotEqualTo, compared)
return NewSingleRule(func(v T) error {
if v == compared {
return errors.New(msg)
}
return nil
}).
WithErrorCode(ErrorCodeNotEqualTo).
WithDescription(msg)
}

func GreaterThan[T constraints.Ordered](n T) SingleRule[T] {
return orderedComparisonRule(cmpGreaterThan, n).
WithErrorCode(ErrorCodeGreaterThan)
}

func GreaterThanOrEqualTo[T constraints.Ordered](n T) SingleRule[T] {
return orderedComparisonRule(cmpGreaterThanOrEqual, n).
WithErrorCode(ErrorCodeGreaterThanOrEqualTo)
}

func LessThan[T constraints.Ordered](n T) SingleRule[T] {
return orderedComparisonRule(cmpLessThan, n).
WithErrorCode(ErrorCodeLessThan)
}

func LessThanOrEqualTo[T constraints.Ordered](n T) SingleRule[T] {
return orderedComparisonRule(cmpLessThanOrEqual, n).
WithErrorCode(ErrorCodeLessThanOrEqualTo)
}

var comparisonFmt = "should be %s '%v'"

func orderedComparisonRule[T constraints.Ordered](op comparisonOperator, compared T) SingleRule[T] {
msg := fmt.Sprintf(comparisonFmt, op, compared)
return NewSingleRule(func(v T) error {
var passed bool
switch op {
case cmpGreaterThan:
passed = v > compared

Check failure on line 62 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run Golang vulnerability check

invalid operation: v > compared (type parameter T is not comparable with >)
case cmpGreaterThanOrEqual:
passed = v >= compared

Check failure on line 64 in pkg/govy/comparable.go

View workflow job for this annotation

GitHub Actions / Run Golang vulnerability check

invalid operation: v >= compared (type parameter T is not comparable with >=)
case cmpLessThan:
passed = v < compared
case cmpLessThanOrEqual:
passed = v <= compared
default:
passed = false
}
if !passed {
return errors.New(msg)
}
return nil
}).WithDescription(msg)
}

type comparisonOperator uint8

const (
cmpEqualTo comparisonOperator = iota
cmpNotEqualTo
cmpGreaterThan
cmpGreaterThanOrEqual
cmpLessThan
cmpLessThanOrEqual
)

func (c comparisonOperator) String() string {
//exhaustive: enforce
switch c {
case cmpEqualTo:
return "equal to"
case cmpNotEqualTo:
return "not equal to"
case cmpGreaterThan:
return "greater than"
case cmpGreaterThanOrEqual:
return "greater than or equal to"
case cmpLessThan:
return "less than"
case cmpLessThanOrEqual:
return "less than or equal to"
default:
return "unknown"
}
}
95 changes: 95 additions & 0 deletions pkg/govy/comparable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package validation

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEqualTo(t *testing.T) {
t.Run("passes", func(t *testing.T) {
err := EqualTo(1.1).Validate(1.1)
assert.NoError(t, err)
})
t.Run("fails", func(t *testing.T) {
err := EqualTo(1.1).Validate(1.3)
require.Error(t, err)
assert.EqualError(t, err, "should be equal to '1.1'")
assert.True(t, HasErrorCode(err, ErrorCodeEqualTo))
})
}

func TestNotEqualTo(t *testing.T) {
t.Run("passes", func(t *testing.T) {
err := NotEqualTo(1.1).Validate(1.3)
assert.NoError(t, err)
})
t.Run("fails", func(t *testing.T) {
err := NotEqualTo(1.1).Validate(1.1)
require.Error(t, err)
assert.EqualError(t, err, "should be not equal to '1.1'")
assert.True(t, HasErrorCode(err, ErrorCodeNotEqualTo))
})
}

func TestGreaterThan(t *testing.T) {
t.Run("passes", func(t *testing.T) {
err := GreaterThan(1).Validate(2)
assert.NoError(t, err)
})
t.Run("fails", func(t *testing.T) {
for n, v := range map[int]int{1: 1, 4: 2} {
err := GreaterThan(n).Validate(v)
require.Error(t, err)
assert.EqualError(t, err, fmt.Sprintf("should be greater than '%v'", n))
assert.True(t, HasErrorCode(err, ErrorCodeGreaterThan))
}
})
}

func TestGreaterThanOrEqual(t *testing.T) {
t.Run("passes", func(t *testing.T) {
for n, v := range map[int]int{1: 1, 2: 4} {
err := GreaterThanOrEqualTo(n).Validate(v)
assert.NoError(t, err)
}
})
t.Run("fails", func(t *testing.T) {
err := GreaterThanOrEqualTo(4).Validate(2)
require.Error(t, err)
assert.EqualError(t, err, "should be greater than or equal to '4'")
assert.True(t, HasErrorCode(err, ErrorCodeGreaterThanOrEqualTo))
})
}

func TestLessThan(t *testing.T) {
t.Run("passes", func(t *testing.T) {
err := LessThan(4).Validate(2)
assert.NoError(t, err)
})
t.Run("fails", func(t *testing.T) {
for n, v := range map[int]int{1: 1, 2: 4} {
err := LessThan(n).Validate(v)
require.Error(t, err)
assert.EqualError(t, err, fmt.Sprintf("should be less than '%v'", n))
assert.True(t, HasErrorCode(err, ErrorCodeLessThan))
}
})
}

func TestLessThanOrEqual(t *testing.T) {
t.Run("passes", func(t *testing.T) {
for n, v := range map[int]int{1: 1, 4: 2} {
err := LessThanOrEqualTo(n).Validate(v)
assert.NoError(t, err)
}
})
t.Run("fails", func(t *testing.T) {
err := LessThanOrEqualTo(2).Validate(4)
require.Error(t, err)
assert.EqualError(t, err, "should be less than or equal to '2'")
assert.True(t, HasErrorCode(err, ErrorCodeLessThanOrEqualTo))
})
}
Loading

0 comments on commit c18a23d

Please sign in to comment.