diff --git a/README.md b/README.md index 3cba6d4..85f58e8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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) { + + // display only the first error + if e, ok := errs["name"]; ok { +

{e[0].Message}

+ } +} +``` + +#### 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 @@ -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())` diff --git a/boolean.go b/boolean.go index 9b4ffda..a08e2df 100644 --- a/boolean.go +++ b/boolean.go @@ -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("") diff --git a/numbers.go b/numbers.go index f6beefa..b2c8c3c 100644 --- a/numbers.go +++ b/numbers.go @@ -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() diff --git a/primitives/errors.go b/primitives/errors.go index c999736..b577efc 100644 --- a/primitives/errors.go +++ b/primitives/errors.go @@ -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 { @@ -25,7 +25,7 @@ type ZogErrors interface { } type ErrsList struct { - List ZogErrorList + List ZogErrList } func NewErrsList() *ErrsList { @@ -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) } @@ -45,7 +45,7 @@ func (e *ErrsList) IsEmpty() bool { // map implementation of Errs type ErrsMap struct { - M ZogSchemaErrors + M ZogErrMap } const ( @@ -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} } diff --git a/slices.go b/slices.go index 14a9e88..2134008 100644 --- a/slices.go +++ b/slices.go @@ -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("") diff --git a/slices_test.go b/slices_test.go index 4febc53..bf0b79d 100644 --- a/slices_test.go +++ b/slices_test.go @@ -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) } diff --git a/string.go b/string.go index e11e874..a127f90 100644 --- a/string.go +++ b/string.go @@ -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() diff --git a/struct.go b/struct.go index e056b87..f1dc148 100644 --- a/struct.go +++ b/struct.go @@ -2,6 +2,7 @@ package zog import ( "fmt" + "maps" "reflect" "unicode" @@ -9,7 +10,7 @@ import ( ) 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 @@ -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("") diff --git a/struct_test.go b/struct_test.go index 8164932..280d006 100644 --- a/struct_test.go +++ b/struct_test.go @@ -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) +} diff --git a/time.go b/time.go index 122ce5f..b655cda 100644 --- a/time.go +++ b/time.go @@ -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("") diff --git a/utils.go b/utils.go index 37d6f81..6677723 100644 --- a/utils.go +++ b/utils.go @@ -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 diff --git a/zhttp/zhttp_tests.go b/zhttp/zhttp_tests.go index 76ca350..fb66adc 100644 --- a/zhttp/zhttp_tests.go +++ b/zhttp/zhttp_tests.go @@ -1,101 +1,102 @@ package zhttp -// import ( -// "net/http" -// "strings" -// "testing" - -// p "github.com/Oudwins/zog/primitives" -// "github.com/stretchr/testify/assert" -// ) - -// func TestRequest(t *testing.T) { -// formData := "name=JohnDoe&email=john@doe.com&age=30&isMarried=true&lights=on&cash=10.5&swagger=doweird" - -// // Create a fake HTTP request with form data -// req, err := http.NewRequest("POST", "/submit?foo=bar&bar=foo&foo=baz", strings.NewReader(formData)) -// if err != nil { -// t.Fatalf("Error creating request: %v", err) -// } -// req.Header.Set("Content-Type", "application/x-www-form-urlencoded") -// type User struct { -// Email string `form:"email"` -// Name string `form:"name"` -// Age int `form:"age"` -// IsMarried bool `form:"isMarried"` -// Lights bool `form:"lights"` -// Cash float64 `form:"cash"` -// Swagger string `form:"swagger"` -// } -// schema := Schema{ -// "email": String().Email(), -// "name": String().Min(3).Max(10), -// "age": Int().GT(18), -// "isMarried": Bool().True(), -// "lights": Bool().True(), -// "cash": Float().GT(10.0), -// "swagger": String().Refine("swagger", "should be doweird", func(rule p.Rule) bool { -// return rule.FieldValue.(string) == "doweird" -// }), -// } -// u := User{} - -// errs, ok := Request(req, &u, schema) - -// assert.Equal(t, "john@doe.com", u.Email) -// assert.Equal(t, "JohnDoe", u.Name) -// assert.Equal(t, 30, u.Age) -// assert.True(t, u.IsMarried) -// assert.True(t, u.Lights) -// assert.Equal(t, 10.5, u.Cash) -// assert.Equal(t, u.Swagger, "doweird") -// assert.Empty(t, errs) -// assert.True(t, ok) -// } - -// func TestRequestParams(t *testing.T) { -// formData := "name=JohnDoe&email=john@doe.com&age=30&age=20&isMarried=true&lights=on&cash=10.5&swagger=doweird&swagger=swagger" - -// // Create a fake HTTP request with form data -// req, err := http.NewRequest("POST", "/submit?"+formData, nil) -// if err != nil { -// t.Fatalf("Error creating request: %v", err) -// } - -// type User struct { -// Email string `param:"email"` -// Name string `param:"name"` -// Age int `param:"age"` -// IsMarried bool `param:"isMarried"` -// Lights bool `param:"lights"` -// Cash float64 `param:"cash"` -// Swagger []string `param:"swagger"` -// } - -// schema := Schema{ -// "email": String().Email(), -// "name": String().Min(3).Max(10), -// "age": Int().GT(18), -// "isMarried": Bool().True(), -// "lights": Bool().True(), -// "cash": Float().GT(10.0), -// "swagger": Slice( -// String().Min(1)).Min(2), -// } -// u := User{} - -// errs, ok := RequestParams(req, &u, schema) - -// assert.Equal(t, "john@doe.com", u.Email) -// assert.Equal(t, "JohnDoe", u.Name) -// assert.Equal(t, 30, u.Age) -// assert.True(t, u.IsMarried) -// assert.True(t, u.Lights) -// assert.Equal(t, 10.5, u.Cash) -// assert.Equal(t, u.Swagger, []string{"doweird", "swagger"}) -// assert.Empty(t, errs) -// assert.True(t, ok) -// } +import ( + "net/http" + "strings" + "testing" + + z "github.com/Oudwins/zog" + "github.com/stretchr/testify/assert" +) + +func TestRequest(t *testing.T) { + formData := "name=JohnDoe&email=john@doe.com&age=30&isMarried=true&lights=on&cash=10.5&swagger=doweird" + + // Create a fake HTTP request with form data + req, err := http.NewRequest("POST", "/submit", strings.NewReader(formData)) + if err != nil { + t.Fatalf("Error creating request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + type User struct { + Email string `zog:"email"` + Name string `zog:"name"` + Age int `zog:"age"` + IsMarried bool `zog:"isMarried"` + Lights bool `zog:"lights"` + Cash float64 `zog:"cash"` + Swagger string `zog:"swagger"` + } + schema := z.Struct(z.Schema{ + "email": z.String().Email(), + "name": z.String().Min(3).Max(10), + "age": z.Int().GT(18), + "isMarried": z.Bool().True(), + "lights": z.Bool().True(), + "cash": z.Float().GT(10.0), + "swagger": z.String().Test("swagger", z.Message("should be doweird"), func(val any, ctx *z.ParseCtx) bool { + return val.(string) == "doweird" + }), + }) + u := User{} + + dp, err := NewRequestDataProvider(req) + assert.Nil(t, err) + errs := schema.Parse(dp, &u) + + assert.Equal(t, "john@doe.com", u.Email) + assert.Equal(t, "JohnDoe", u.Name) + assert.Equal(t, 30, u.Age) + assert.True(t, u.IsMarried) + assert.True(t, u.Lights) + assert.Equal(t, 10.5, u.Cash) + assert.Equal(t, u.Swagger, "doweird") + assert.Empty(t, errs) +} + +func TestRequestParams(t *testing.T) { + formData := "name=JohnDoe&email=john@doe.com&age=30&age=20&isMarried=true&lights=on&cash=10.5&swagger=doweird&swagger=swagger" + + // Create a fake HTTP request with form data + req, err := http.NewRequest("POST", "/submit?"+formData, nil) + if err != nil { + t.Fatalf("Error creating request: %v", err) + } + + type User struct { + Email string `param:"email"` + Name string `param:"name"` + Age int `param:"age"` + IsMarried bool `param:"isMarried"` + Lights bool `param:"lights"` + Cash float64 `param:"cash"` + Swagger []string `param:"swagger"` + } + + schema := z.Struct(z.Schema{ + "email": z.String().Email(), + "name": z.String().Min(3).Max(10), + "age": z.Int().GT(18), + "isMarried": z.Bool().True(), + "lights": z.Bool().True(), + "cash": z.Float().GT(10.0), + "swagger": z.Slice( + z.String().Min(1)).Min(2), + }) + u := User{} + dp, err := NewRequestDataProvider(req) + assert.Nil(t, err) + errs := schema.Parse(dp, &u) + + assert.Equal(t, "john@doe.com", u.Email) + assert.Equal(t, "JohnDoe", u.Name) + assert.Equal(t, 30, u.Age) + assert.True(t, u.IsMarried) + assert.True(t, u.Lights) + assert.Equal(t, 10.5, u.Cash) + assert.Equal(t, u.Swagger, []string{"doweird", "swagger"}) + assert.Empty(t, errs) +} // func TestStringURL(t *testing.T) { // type Foo struct {