Skip to content

Commit

Permalink
feat: quality of life improvements for working with errors (#3)
Browse files Browse the repository at this point in the history
* feat: implemented and documented schema merging

* refactor: renamed zog errors

* test: http tests

* feat: export zog errors & sanitize functions

* docs: documented working with errors
  • Loading branch information
Oudwins authored Aug 16, 2024
1 parent 7b5bca5 commit 1f3c3d0
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 113 deletions.
98 changes: 95 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ import (
z "github.com/Oudwins/zog"
)

var schema = z.Struct(z.Schema{
var nameSchema = z.Struct(z.Schema{
"name": z.String().Min(3, z.Message("Override default message")).Max(10),
})

var ageSchema = z.Struct(z.Schema{
"age": z.Int().GT(18).Required(z.Message("is required")),
})

// Merge the schemas creating a new schema
var schema = nameSchema.Merge(ageSchema)

type User struct {
Name string `zog:"name"` // optional zog will use field name by default
Age int
Expand All @@ -64,7 +70,7 @@ func main() {
}
errsMap := schema.Parse(z.NewMapDataProvider(m), &u)
if errsMap != nil {
// handle errors
// handle errors -> see Errors section
}
u.Name // ""
u.Age // 30
Expand Down Expand Up @@ -205,6 +211,92 @@ func handlePostRequest(w http.ResponseWriter, r *http.Request) {

```

## Errors

> **WARNING**: The errors API is probably what is most likely to change in the future. I will try to keep it backwards compatible but I can't guarantee it.
Zog creates its own error type called `ZogError` that implements the error interface.

```go
type ZogError struct {
Message string
Err error
}
```

This is what will be returned by the `Parse` function. To be precise:

- Primitive types will return a list of `ZogError` instances.
- Complex types will return a map of `ZogError` instances. Which uses the field path as the key & the list of errors as the value.

For example:

```go
errList := z.String().Min(5).Parse("foo", &dest) // can return []z.ZogError{z.ZogError{Message: "min length is 5"}} or nil
errMap := z.Struct(z.Schema{"name": z.String().Min(5)}).Parse(data, &dest) // can return map[string][]z.ZogError{"name": []z.ZogError{{Message: "min length is 5"}}} or nil

// Slice of 2 strings with min length of 5
errsMap2 := z.Slice(z.String().Min(5)).Len(2).Parse(data, &dest) // can return map[string][]z.ZogError{"$root": []z.ZogError{{Message: "slice length is not 2"}, "indexOfElementThatFailed": []z.ZogError{{Message: "min length is 5"}}}} or nil
```

Additionally, `z.ZogErrMap` will use the field path as the key. Meaning

```go
errsMap := z.Struct(z.Schema{"inner": z.Struct(z.Schema{"name": z.String().Min(5)})}).Parse(data, &dest)
errsMap["inner.name"] // will return []z.ZogError{{Message: "min length is 5"}}
```

`$root` is a reserved key that will be used for the root level errors. For example:

```go
errsMap := z.Slice(z.String()).Min(2).Parse(data, &dest)
errsMap["$root"] // will return []z.ZogError{{Message: "slice length is not 2"}}
```

### Example ways of delivering errors to users

#### Using go templ templates

Imagine our handler looks like this:

```go
errs := schema.Parse(zhttp.NewRequestDataProvider(r), &userFormData)

if errs != nil {
www.Render(templates.Form(errs))
}
```

Now inside our form template we can do something like this:

```go

templ Form(errs z.ZogErrMap) {
<input type="text" name="name" value="">
// display only the first error
if e, ok := errs["name"]; ok {
<p class="error">{e[0].Message}</p>
}
}
```

#### REST API Responses

Zog providers a helper function called `z.Errors.SanitizeMap(errsMap)` that will return a map of strings of the error messages (stripping out the internal error). So, if you do not mind sending errors to your users in the same form zog returns them, you can do something like this:

```go
errs := schema.Parse(data, &userFormData)

if errs != nil {
sanitized := z.Errors.SanitizeMap(errs)
// sanitize will be map[string][]string
// for example:
// {"name": []string{"min length is 5", "max length is 10"}, "email": []string{"is not a valid email"}}
// send this to the user somehow
}

```

## Reference

### Generic Schema Methods
Expand Down Expand Up @@ -376,7 +468,7 @@ conf.Coercers["float64"] = func(data any) (any, error) {
These are the things I want to add to zog before v1.0.0

- For structs & slices: support pointers
- Support for schema.Merge(schema2) && schema.Clone()
- Support for schema.Clone()
- Better support for custom error messages (including failed coercion error messages) & i18n
- support for catch & default for structs & slices
- implement errors.SanitizeMap/Slice -> Will leave only the safe error messages. No internal stuff. Optionally this could be a parsing option in the style of `schema.Parse(m, &dest, z.WithSanitizeErrors())`
Expand Down
2 changes: 1 addition & 1 deletion boolean.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func Bool() *boolProcessor {
}
}

func (v *boolProcessor) Parse(data any, dest *bool) p.ZogErrorList {
func (v *boolProcessor) Parse(data any, dest *bool) p.ZogErrList {
var ctx = p.NewParseCtx()
errs := p.NewErrsList()
path := p.PathBuilder("")
Expand Down
2 changes: 1 addition & 1 deletion numbers.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func Int() *numberProcessor[int] {
}

// parses the value and stores it in the destination
func (v *numberProcessor[T]) Parse(val any, dest *T) p.ZogErrorList {
func (v *numberProcessor[T]) Parse(val any, dest *T) p.ZogErrList {
// TODO create context -> but for single field
var ctx = p.NewParseCtx()
errs := p.NewErrsList()
Expand Down
12 changes: 6 additions & 6 deletions primitives/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ func (e ZogError) Unwrap() error {
}

// list of errors. This is returned for each specific processor
type ZogErrorList = []ZogError
type ZogSchemaErrors = map[string][]ZogError
type ZogErrList = []ZogError
type ZogErrMap = map[string][]ZogError

// Interface used to add errors during parsing & validation
type ZogErrors interface {
Expand All @@ -25,7 +25,7 @@ type ZogErrors interface {
}

type ErrsList struct {
List ZogErrorList
List ZogErrList
}

func NewErrsList() *ErrsList {
Expand All @@ -34,7 +34,7 @@ func NewErrsList() *ErrsList {

func (e *ErrsList) Add(path PathBuilder, err ZogError) {
if e.List == nil {
e.List = make(ZogErrorList, 0, 3)
e.List = make(ZogErrList, 0, 3)
}
e.List = append(e.List, err)
}
Expand All @@ -45,7 +45,7 @@ func (e *ErrsList) IsEmpty() bool {

// map implementation of Errs
type ErrsMap struct {
M ZogSchemaErrors
M ZogErrMap
}

const (
Expand All @@ -61,7 +61,7 @@ func NewErrsMap() *ErrsMap {
func (s *ErrsMap) Add(p PathBuilder, err ZogError) {
// checking if its the first error
if s.M == nil {
s.M = ZogSchemaErrors{}
s.M = ZogErrMap{}
s.M[ERROR_KEY_FIRST] = []ZogError{err}
}

Expand Down
2 changes: 1 addition & 1 deletion slices.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func Slice(schema Processor) *sliceProcessor {
}

// only supports val = slice[any] & dest = &slice[]
func (v *sliceProcessor) Parse(val any, dest any) p.ZogSchemaErrors {
func (v *sliceProcessor) Parse(val any, dest any) p.ZogErrMap {
var ctx = p.NewParseCtx()
errs := p.NewErrsMap()
path := p.PathBuilder("")
Expand Down
1 change: 0 additions & 1 deletion slices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,4 @@ func TestSliceDefault(t *testing.T) {
assert.Equal(t, s[0], "a")
assert.Equal(t, s[1], "b")
assert.Equal(t, s[2], "c")
fmt.Println(s)
}
2 changes: 1 addition & 1 deletion string.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func String() *stringProcessor {
}
}

func (v *stringProcessor) Parse(val any, dest *string) p.ZogErrorList {
func (v *stringProcessor) Parse(val any, dest *string) p.ZogErrList {
// TODO create context -> but for single field
var ctx = p.NewParseCtx()
errs := p.NewErrsList()
Expand Down
38 changes: 36 additions & 2 deletions struct.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ package zog

import (
"fmt"
"maps"
"reflect"
"unicode"

p "github.com/Oudwins/zog/primitives"
)

type StructParser interface {
Parse(val p.DataProvider, destPtr any) p.ZogSchemaErrors
Parse(val p.DataProvider, destPtr any) p.ZogErrMap
}

// A map of field names to zog schemas
Expand All @@ -32,8 +33,41 @@ type structProcessor struct {
// catch any
}

func (v *structProcessor) Merge(other *structProcessor) *structProcessor {
new := &structProcessor{
preTransforms: make([]p.PreTransform, len(v.preTransforms)+len(other.preTransforms)),
postTransforms: make([]p.PostTransform, len(v.postTransforms)+len(other.postTransforms)),
tests: make([]p.Test, len(v.tests)+len(other.tests)),
}
if v.preTransforms != nil {
new.preTransforms = append(new.preTransforms, v.preTransforms...)
}
if other.preTransforms != nil {
new.preTransforms = append(new.preTransforms, other.preTransforms...)
}

if v.postTransforms != nil {
new.postTransforms = append(new.postTransforms, v.postTransforms...)
}
if other.postTransforms != nil {
new.postTransforms = append(new.postTransforms, other.postTransforms...)
}

if v.tests != nil {
new.tests = append(new.tests, v.tests...)
}
if other.tests != nil {
new.tests = append(new.tests, other.tests...)
}
new.required = v.required
new.schema = Schema{}
maps.Copy(new.schema, v.schema)
maps.Copy(new.schema, other.schema)
return new
}

// Parses val into destPtr and validates each field based on the schema. Only supports val = map[string]any & dest = &struct
func (v *structProcessor) Parse(val p.DataProvider, destPtr any) p.ZogSchemaErrors {
func (v *structProcessor) Parse(val p.DataProvider, destPtr any) p.ZogErrMap {
var ctx = p.NewParseCtx()
errs := p.NewErrsMap()
path := p.PathBuilder("")
Expand Down
21 changes: 21 additions & 0 deletions struct_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,24 @@ func TestOptionalStructs(t *testing.T) {
errs := objSchema.Parse(&p.EmptyDataProvider{}, &o)
assert.Nil(t, errs)
}

func TestMergeSchema(t *testing.T) {
var nameSchema = Struct(Schema{
"name": String().Min(3, Message("Override default message")).Max(10),
})
var ageSchema = Struct(Schema{
"age": Int().GT(18).Required(Message("is required")),
})
var schema = nameSchema.Merge(ageSchema)

type User struct {
Name string
Age int
}

var o User
errs := schema.Parse(NewMapDataProvider(map[string]any{"name": "hello", "age": 20}), &o)
assert.Nil(t, errs)
assert.Equal(t, o.Name, "hello")
assert.Equal(t, o.Age, 20)
}
2 changes: 1 addition & 1 deletion time.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func Time() *timeProcessor {
return &timeProcessor{}
}

func (v *timeProcessor) Parse(val any, dest *time.Time) p.ZogErrorList {
func (v *timeProcessor) Parse(val any, dest *time.Time) p.ZogErrList {
var ctx = p.NewParseCtx()
errs := p.NewErrsList()
path := p.PathBuilder("")
Expand Down
20 changes: 20 additions & 0 deletions utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,28 @@ func (e *errHelpers) WrapUnknown(err error) p.ZogError {
return zerr
}

func (e *errHelpers) SanitizeMap(m p.ZogErrMap) map[string][]string {
errs := make(map[string][]string, len(m))
for k, v := range m {
errs[k] = e.SanitizeList(v)
}
return errs
}

func (e *errHelpers) SanitizeList(l p.ZogErrList) []string {
errs := make([]string, len(l))
for i, err := range l {
errs[i] = err.Message
}
return errs
}

var Errors = errHelpers{}

type ZogError = p.ZogError
type ZogErrMap = p.ZogErrMap
type ZogErrList = p.ZogErrList

// ! Data Providers

// Creates a new map data provider
Expand Down
Loading

0 comments on commit 1f3c3d0

Please sign in to comment.