From ac639bdaef241f3433718b3c7161ca5ca81ae35e Mon Sep 17 00:00:00 2001 From: jake Date: Fri, 12 Nov 2021 15:20:47 -0600 Subject: [PATCH 1/9] these tests really belong in field_test.go --- field.go | 16 +++++++--- field_test.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++ prehandler_test.go | 42 ------------------------- 3 files changed, 89 insertions(+), 47 deletions(-) create mode 100644 field_test.go diff --git a/field.go b/field.go index 98c1f79..d95dd01 100644 --- a/field.go +++ b/field.go @@ -75,7 +75,7 @@ func (f *Field) Validate(value interface{}) error { switch v := value.(type) { case int: - if f.kind != "integer" { + if f.kind != KindInteger { return errWrongType } if f.max != nil && float64(v) > *f.max { @@ -85,7 +85,7 @@ func (f *Field) Validate(value interface{}) error { return errMinimum } case float64: - if f.kind != "number" { + if f.kind != KindNumber { return errWrongType } if f.max != nil && v > *f.max { @@ -95,7 +95,7 @@ func (f *Field) Validate(value interface{}) error { return errMinimum } case string: - if f.kind != "string" { + if f.kind != KindString { return errWrongType } if f.required != nil && *f.required && v == "" && !f.allow.has("") { @@ -108,11 +108,11 @@ func (f *Field) Validate(value interface{}) error { return errMinimum } case bool: - if f.kind != "boolean" { + if f.kind != KindBoolean { return errWrongType } case []interface{}: - if f.kind != "array" { + if f.kind != KindArray { return errWrongType } if f.min != nil && float64(len(v)) < *f.min { @@ -181,12 +181,18 @@ func Integer() Field { // Min specifies a minimum value for this field func (f Field) Min(min float64) Field { f.min = &min + if f.max != nil && *f.max < min { + panic("min cannot be larger than max") + } return f } // Max specifies a maximum value for this field func (f Field) Max(max float64) Field { f.max = &max + if f.min != nil && *f.min > max { + panic("min cannot be larger than max") + } return f } diff --git a/field_test.go b/field_test.go new file mode 100644 index 0000000..ef79b91 --- /dev/null +++ b/field_test.go @@ -0,0 +1,78 @@ +package crud + +import "testing" + +func TestField_Max_Panic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + String().Min(2).Max(1) +} + +func TestField_Min_Panic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + String().Max(1).Min(2) +} + +func TestField_String(t *testing.T) { + table := []struct { + Field Field + Input interface{} + Expected error + }{ + { + Field: String().Required(), + Input: 7, + Expected: errWrongType, + }, + { + Field: String().Required(), + Input: "", + Expected: errRequired, + }, + { + Field: String().Required().Allow(""), + Input: "", + Expected: nil, + }, + { + Field: String().Required(), + Input: "anything", + Expected: nil, + }, + { + Field: String().Min(1), + Input: "", + Expected: errMinimum, + }, + { + Field: String().Min(1), + Input: "1", + Expected: nil, + }, + { + Field: String().Max(1), + Input: "1", + Expected: nil, + }, + { + Field: String().Max(1), + Input: "12", + Expected: errMaximum, + }, + } + + for i, test := range table { + if v := test.Field.Validate(test.Input); v != test.Expected { + t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) + } + } +} diff --git a/prehandler_test.go b/prehandler_test.go index a83d54d..e511512 100644 --- a/prehandler_test.go +++ b/prehandler_test.go @@ -330,48 +330,6 @@ func TestBodyValidation(t *testing.T) { Input string Expected error }{ - { - Schema: map[string]Field{ - "str": String().Required(), - }, - Input: `{"str":""}`, - Expected: errRequired, - }, - { - Schema: map[string]Field{ - "str": String().Required().Allow(""), - }, - Input: `{"str":""}`, - Expected: nil, - }, - { - Schema: map[string]Field{ - "str": String().Min(7), - }, - Input: `{"str":""}`, - Expected: errMinimum, - }, - { - Schema: map[string]Field{ - "str": String().Min(7), - }, - Input: `{"str":"123456"}`, - Expected: errMinimum, - }, - { - Schema: map[string]Field{ - "str": String().Max(3), - }, - Input: `{"str":"123"}`, - Expected: nil, - }, - { - Schema: map[string]Field{ - "str": String().Max(3), - }, - Input: `{"str":"1234"}`, - Expected: errMaximum, - }, { Schema: map[string]Field{ "int": Integer(), From 24a253904837730121cc8d09917663be057f428a Mon Sep 17 00:00:00 2001 From: jake Date: Fri, 12 Nov 2021 15:38:42 -0600 Subject: [PATCH 2/9] cover all the basic types fully --- field.go | 3 + field_test.go | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) diff --git a/field.go b/field.go index d95dd01..17feedd 100644 --- a/field.go +++ b/field.go @@ -251,6 +251,9 @@ func (f Field) Default(value interface{}) Field { // Enum restricts the field's values to the set of values specified func (f Field) Enum(values ...interface{}) Field { + if f.kind == KindArray || f.kind == KindObject || f.kind == KindFile { + panic("Enum cannot be used on arrays, objects, files") + } f.enum = values return f } diff --git a/field_test.go b/field_test.go index ef79b91..fa6a878 100644 --- a/field_test.go +++ b/field_test.go @@ -28,6 +28,16 @@ func TestField_String(t *testing.T) { Input interface{} Expected error }{ + { + Field: String(), + Input: nil, + Expected: nil, + }, + { + Field: String().Required(), + Input: nil, + Expected: errRequired, + }, { Field: String().Required(), Input: 7, @@ -68,6 +78,256 @@ func TestField_String(t *testing.T) { Input: "12", Expected: errMaximum, }, + { + Field: String().Enum("hi"), + Input: "", + Expected: errEnumNotFound, + }, + { + Field: String().Enum("hi"), + Input: "hi", + Expected: nil, + }, + } + + for i, test := range table { + if v := test.Field.Validate(test.Input); v != test.Expected { + t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) + } + } +} + +func TestField_Integer(t *testing.T) { + table := []struct { + Field Field + Input interface{} + Expected error + }{ + { + Field: Integer(), + Input: 7.2, + Expected: errWrongType, + }, + { + Field: Integer(), + Input: nil, + Expected: nil, + }, + { + Field: Integer().Required(), + Input: nil, + Expected: errRequired, + }, + { + Field: Integer().Required(), + Input: 0, + Expected: nil, + }, + { + Field: Integer().Min(1), + Input: 0, + Expected: errMinimum, + }, + { + Field: Integer().Min(1), + Input: 1, + Expected: nil, + }, + { + Field: Integer().Max(1), + Input: 1, + Expected: nil, + }, + { + Field: Integer().Max(1), + Input: 12, + Expected: errMaximum, + }, + { + Field: Integer().Enum(1, 2, 3), + Input: 4, + Expected: errEnumNotFound, + }, + { + Field: Integer().Enum(1, 2, 3), + Input: 2, + Expected: nil, + }, + } + + for i, test := range table { + if v := test.Field.Validate(test.Input); v != test.Expected { + t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) + } + } +} + +func TestField_Float64(t *testing.T) { + table := []struct { + Field Field + Input interface{} + Expected error + }{ + { + Field: Number(), + Input: "7", + Expected: errWrongType, + }, + { + Field: Number(), + Input: nil, + Expected: nil, + }, + { + Field: Number().Required(), + Input: nil, + Expected: errRequired, + }, + { + Field: Number().Required(), + Input: 0., + Expected: nil, + }, + { + Field: Number().Min(1.1), + Input: 0., + Expected: errMinimum, + }, + { + Field: Number().Min(1.1), + Input: 1.1, + Expected: nil, + }, + { + Field: Number().Max(1.2), + Input: 1.2, + Expected: nil, + }, + { + Field: Number().Max(1), + Input: 12., + Expected: errMaximum, + }, + { + Field: Number().Enum(1., 2.), + Input: 3., + Expected: errEnumNotFound, + }, + { + Field: Number().Enum(1., 2.), + Input: 1., + Expected: nil, + }, + } + + for i, test := range table { + if v := test.Field.Validate(test.Input); v != test.Expected { + t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) + } + } +} + +func TestField_Boolean(t *testing.T) { + table := []struct { + Field Field + Input interface{} + Expected error + }{ + { + Field: Boolean(), + Input: "true", + Expected: errWrongType, + }, + { + Field: Boolean(), + Input: nil, + Expected: nil, + }, + { + Field: Boolean().Required(), + Input: nil, + Expected: errRequired, + }, + { + Field: Boolean().Required(), + Input: true, + Expected: nil, + }, + { + Field: Boolean(), + Input: false, + Expected: nil, + }, + { + Field: Boolean().Enum(true), + Input: false, + Expected: errEnumNotFound, + }, + { + Field: Boolean().Enum(true), + Input: true, + Expected: nil, + }, + } + + for i, test := range table { + if v := test.Field.Validate(test.Input); v != test.Expected { + t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) + } + } +} + +func TestField_Array(t *testing.T) { + table := []struct { + Field Field + Input interface{} + Expected error + }{ + { + Field: Array(), + Input: true, + Expected: errWrongType, + }, + { + Field: Array(), + Input: nil, + Expected: nil, + }, + { + Field: Array().Required(), + Input: nil, + Expected: errRequired, + }, + { + Field: Array().Required(), + Input: []interface{}{}, + Expected: nil, + }, + { + Field: Array(), + Input: []interface{}{1}, + Expected: nil, + }, + { + Field: Array().Min(1), + Input: []interface{}{}, + Expected: errMinimum, + }, + { + Field: Array().Min(1), + Input: []interface{}{1}, + Expected: nil, + }, + { + Field: Array().Max(1), + Input: []interface{}{1, 2}, + Expected: errMaximum, + }, + { + Field: Array().Max(1), + Input: []interface{}{1}, + Expected: nil, + }, } for i, test := range table { From 1f4b392408fdf56b2a9c06d27ca546fe61338fa7 Mon Sep 17 00:00:00 2001 From: jake Date: Mon, 15 Nov 2021 08:23:35 -0600 Subject: [PATCH 3/9] moved validateBody to a generic object validator also a lot of cleanup and clearer testing --- field.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++ field_test.go | 65 +++++++++++++++++++++++++++++++- prehandler.go | 89 ++++++-------------------------------------- prehandler_test.go | 8 ++-- 4 files changed, 172 insertions(+), 82 deletions(-) diff --git a/field.go b/field.go index 17feedd..561b2ef 100644 --- a/field.go +++ b/field.go @@ -2,6 +2,7 @@ package crud import ( "fmt" + "reflect" "strings" ) @@ -121,6 +122,11 @@ func (f *Field) Validate(value interface{}) error { if f.max != nil && float64(len(v)) > *f.max { return errMaximum } + case map[string]interface{}: + if f.kind != KindObject { + return errWrongType + } + return validateObject("", f, v) default: return fmt.Errorf("unhandled type %v", v) } @@ -132,6 +138,78 @@ func (f *Field) Validate(value interface{}) error { return nil } +// validateObject is a recursive function that validates the field values in the object. It also +// performs stripping of values, or erroring when unexpected fields are present, depending on the +// options on the fields. +func validateObject(name string, field *Field, body interface{}) error { + switch v := body.(type) { + case nil: + if field.required != nil && *field.required { + return fmt.Errorf("object validation failed for field %v: %w", name, errRequired) + } + case string, bool: + if err := field.Validate(v); err != nil { + return fmt.Errorf("object validation failed for field %v: %w", name, err) + } + case float64: + if field.kind == KindInteger { + // JSON doesn't have integers, so Go treats these fields as float64. + // Need to convert to integer before validating it. + if v != float64(int64(v)) { + return fmt.Errorf("object validation failed for field %v: %w", name, errWrongType) + } + if err := field.Validate(int(v)); err != nil { + return fmt.Errorf("object validation failed for field %v: %w", name, err) + } + } else { + if err := field.Validate(v); err != nil { + return fmt.Errorf("object validation failed for field %v: %w", name, err) + } + } + case []interface{}: + if err := field.Validate(v); err != nil { + return fmt.Errorf("object validation failed for field %v: %w", name, err) + } + if field.arr != nil { + for i, item := range v { + if err := validateObject(fmt.Sprintf("%v[%v]", name, i), field.arr, item); err != nil { + return err + } + } + } + case map[string]interface{}: + if !field.isAllowUnknown() { + for key := range v { + if _, ok := field.obj[key]; !ok { + return fmt.Errorf("unknown field in object: %v %w", key, errUnknown) + } + } + } + + if field.isStripUnknown() { + for key := range v { + if _, ok := field.obj[key]; !ok { + delete(v, key) + } + } + } + + for childName, childField := range field.obj { + newV := v[childName] + if newV == nil && childField.required != nil && *childField.required { + return fmt.Errorf("object validation failed for field %v.%v: %w", name, childName, errRequired) + } else if newV == nil && childField._default != nil { + v[childName] = childField._default + } else if err := validateObject(name+"."+childName, &childField, v[childName]); err != nil { + return err + } + } + default: + return fmt.Errorf("object validation failed for type %v: %w", reflect.TypeOf(v), errWrongType) + } + return nil +} + // These kinds correlate to swagger and json types. const ( KindNumber = "number" @@ -373,3 +451,17 @@ func (f *Field) ToJsonSchema() JsonSchema { } return schema } + +func (f Field) isAllowUnknown() bool { + if f.unknown == nil { + return true // by default allow unknown + } + return *f.unknown +} + +func (f Field) isStripUnknown() bool { + if f.strip == nil { + return true // by default strip + } + return *f.strip +} diff --git a/field_test.go b/field_test.go index fa6a878..3cb62ee 100644 --- a/field_test.go +++ b/field_test.go @@ -1,6 +1,9 @@ package crud -import "testing" +import ( + "errors" + "testing" +) func TestField_Max_Panic(t *testing.T) { defer func() { @@ -336,3 +339,63 @@ func TestField_Array(t *testing.T) { } } } + +func TestField_Object(t *testing.T) { + table := []struct { + Field Field + Input interface{} + Expected error + }{ + { + Field: Object(map[string]Field{}), + Input: "", + Expected: errWrongType, + }, + { + Field: Object(map[string]Field{}), + Input: nil, + Expected: nil, + }, + { + Field: Object(map[string]Field{}).Required(), + Input: nil, + Expected: errRequired, + }, + { + Field: Object(map[string]Field{}).Required(), + Input: map[string]interface{}{}, + Expected: nil, + }, + { + Field: Object(map[string]Field{ + "nested": Integer().Required().Min(1), + }), + Input: map[string]interface{}{}, + Expected: errRequired, + }, + { + Field: Object(map[string]Field{ + "nested": Integer().Required().Min(1), + }), + Input: map[string]interface{}{ + "nested": 1.1, + }, + Expected: errWrongType, + }, + { + Field: Object(map[string]Field{ + "nested": Integer().Required().Min(1), + }), + Input: map[string]interface{}{ + "nested": 1., + }, + Expected: nil, + }, + } + + for i, test := range table { + if v := test.Field.Validate(test.Input); !errors.Is(v, test.Expected) { + t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) + } + } +} diff --git a/prehandler.go b/prehandler.go index 9b3ae35..e9e9925 100644 --- a/prehandler.go +++ b/prehandler.go @@ -3,7 +3,6 @@ package crud import ( "fmt" "net/url" - "reflect" "strconv" ) @@ -29,7 +28,7 @@ func (r *Router) Validate(val Validate, query url.Values, body interface{}, path } } if schema.kind == KindArray { - // sadly we have to convert to a []interface{} to simplify the validate code + // sadly we have to convert to a []interface{} to simplify the validation code var intray []interface{} for _, v := range queryValue { intray = append(intray, v) @@ -61,8 +60,15 @@ func (r *Router) Validate(val Validate, query url.Values, body interface{}, path } if val.Body.Initialized() && val.Body.kind != KindFile { - err := r.validateBody("body", &val.Body, body) - if err != nil { + // use router defaults if the object doesn't have anything set + f := val.Body + if f.strip == nil { + f = f.Strip(r.stripUnknown) + } + if f.unknown == nil { + f = f.Unknown(r.allowUnknown) + } + if err := f.Validate(body); err != nil { return err } } @@ -84,79 +90,8 @@ func (r *Router) Validate(val Validate, query url.Values, body interface{}, path return nil } -func (r *Router) validateBody(name string, field *Field, body interface{}) error { - switch v := body.(type) { - case nil: - if field.required != nil && *field.required { - return fmt.Errorf("body validation failed for field %v: %w", name, errRequired) - } - case string: - if err := field.Validate(v); err != nil { - return fmt.Errorf("body validation failed for field %v: %w", name, err) - } - case bool: - if err := field.Validate(v); err != nil { - return fmt.Errorf("body validation failed for field %v: %w", name, err) - } - case float64: - if field.kind == KindInteger { - // JSON doesn't have integers, so Go treats these fields as float64. - // Need to convert to integer before validating it. - if v != float64(int64(v)) { - return fmt.Errorf("body validation failed for field %v: %w", name, errWrongType) - } - if err := field.Validate(int(v)); err != nil { - return fmt.Errorf("body validation failed for field %v: %w", name, err) - } - } else { - if err := field.Validate(v); err != nil { - return fmt.Errorf("body validation failed for field %v: %w", name, err) - } - } - case []interface{}: - if err := field.Validate(v); err != nil { - return fmt.Errorf("body validation failed for field %v: %w", name, err) - } - if field.arr != nil { - for i, item := range v { - if err := r.validateBody(fmt.Sprintf("%v[%v]", name, i), field.arr, item); err != nil { - return err - } - } - } - case map[string]interface{}: - if (field.unknown != nil && !*field.unknown) || (field.unknown == nil && !r.allowUnknown) { - for key := range v { - if _, ok := field.obj[key]; !ok { - return fmt.Errorf("unknown field in body: %v %w", key, errUnknown) - } - } - } - - if (field.strip != nil && *field.strip) || (field.strip == nil && r.stripUnknown) { - for key := range v { - if _, ok := field.obj[key]; !ok { - delete(v, key) - } - } - } - - for name, field := range field.obj { - newV := v[name] - if newV == nil && field.required != nil && *field.required { - return fmt.Errorf("body validation failed for field %v: %w", name, errRequired) - } else if newV == nil && field._default != nil { - v[name] = field._default - } else if err := r.validateBody(name, &field, v[name]); err != nil { - return err - } - } - default: - return fmt.Errorf("body validation failed for type %v: %w", reflect.TypeOf(v), errWrongType) - } - return nil -} - +// For certain types of data passed like Query and Header, the value is always +// a string. So this function attempts to convert the string into the desired field kind. func convert(inputValue string, schema Field) (interface{}, error) { // don't try to convert if the field is empty if inputValue == "" { diff --git a/prehandler_test.go b/prehandler_test.go index e511512..f769e33 100644 --- a/prehandler_test.go +++ b/prehandler_test.go @@ -316,7 +316,7 @@ func TestSimpleBodyValidation(t *testing.T) { for _, test := range tests { err := r.Validate(Validate{Body: test.Schema}, nil, test.Input, nil) - if errors.Unwrap(err) != test.Expected { + if !errors.Is(err, test.Expected) { t.Errorf("expected '%v' got '%v'. input: '%v'. schema: '%v'", test.Expected, err, test.Input, test.Schema) } } @@ -520,7 +520,7 @@ func TestBodyErrorUnknown(t *testing.T) { }, } - for _, test := range tests { + for i, test := range tests { var input interface{} if err := json.Unmarshal([]byte(test.Input), &input); err != nil { t.Fatal(err) @@ -528,8 +528,8 @@ func TestBodyErrorUnknown(t *testing.T) { err := r.Validate(Validate{Body: Object(test.Schema)}, nil, input, nil) - if !errors.As(err, &errUnknown) { - t.Error(err) + if !errors.Is(err, errUnknown) { + t.Errorf("%v: expected '%v' got '%v'", i, errUnknown, err) continue } } From a90856a756b2acb0fbb322d04554d1d41e414863 Mon Sep 17 00:00:00 2001 From: Jake Coffman Date: Wed, 17 Nov 2021 17:08:44 -0600 Subject: [PATCH 4/9] Update bug_report.md --- .github/ISSUE_TEMPLATE/bug_report.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 73f6043..3266115 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -11,6 +11,10 @@ assignees: '' A clear and concise description of what the bug is. +Please include: +- The crud version from `go.mod` +- What router and adapter you are using + If this is more of a question, please use Discussions instead, don't open an issue. **To reproduce the bug** From 7022a867a0d5b0cbf72f2218407e93b958e6b0c0 Mon Sep 17 00:00:00 2001 From: jake Date: Thu, 18 Nov 2021 19:40:22 -0600 Subject: [PATCH 5/9] fix #7 by passing settings down through obj/arr Due to the previous refactor, settings no longer are "global" so I needed a new way to propagate to children. --- field.go | 30 ++++++++++-- field_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++++ prehandler.go | 9 ++-- prehandler_test.go | 4 +- 4 files changed, 145 insertions(+), 9 deletions(-) diff --git a/field.go b/field.go index 561b2ef..a1def38 100644 --- a/field.go +++ b/field.go @@ -122,6 +122,20 @@ func (f *Field) Validate(value interface{}) error { if f.max != nil && float64(len(v)) > *f.max { return errMaximum } + if f.arr != nil { + // child fields inherit parent's settings, unless specified on child + if f.arr.strip == nil { + f.arr.strip = f.strip + } + if f.arr.unknown == nil { + f.arr.unknown = f.unknown + } + for _, item := range v { + if err := f.arr.Validate(item); err != nil { + return err + } + } + } case map[string]interface{}: if f.kind != KindObject { return errWrongType @@ -141,8 +155,8 @@ func (f *Field) Validate(value interface{}) error { // validateObject is a recursive function that validates the field values in the object. It also // performs stripping of values, or erroring when unexpected fields are present, depending on the // options on the fields. -func validateObject(name string, field *Field, body interface{}) error { - switch v := body.(type) { +func validateObject(name string, field *Field, input interface{}) error { + switch v := input.(type) { case nil: if field.required != nil && *field.required { return fmt.Errorf("object validation failed for field %v: %w", name, errRequired) @@ -195,6 +209,14 @@ func validateObject(name string, field *Field, body interface{}) error { } for childName, childField := range field.obj { + // child fields inherit parent's settings, unless specified on child + if childField.strip == nil { + childField.strip = field.strip + } + if childField.unknown == nil { + childField.unknown = field.unknown + } + newV := v[childName] if newV == nil && childField.required != nil && *childField.required { return fmt.Errorf("object validation failed for field %v.%v: %w", name, childName, errRequired) @@ -352,13 +374,13 @@ func (f Field) Allow(values ...interface{}) Field { return f } -// Strip overrides the global "strip unknown" setting just for this field +// Strip overrides the global "strip unknown" setting just for this field, and all children of this field func (f Field) Strip(strip bool) Field { f.strip = &strip return f } -// Unknown overrides the global "allow unknown" setting just for this field +// Unknown overrides the global "allow unknown" setting just for this field, and all children of this field func (f Field) Unknown(allow bool) Field { f.unknown = &allow return f diff --git a/field_test.go b/field_test.go index 3cb62ee..d54e33b 100644 --- a/field_test.go +++ b/field_test.go @@ -2,6 +2,7 @@ package crud import ( "errors" + "fmt" "testing" ) @@ -331,6 +332,21 @@ func TestField_Array(t *testing.T) { Input: []interface{}{1}, Expected: nil, }, + { + Field: Array().Items(String()), + Input: []interface{}{}, + Expected: nil, + }, + { + Field: Array().Items(String().Required()), + Input: []interface{}{""}, + Expected: errRequired, + }, + { + Field: Array().Items(String().Required()), + Input: []interface{}{"hi"}, + Expected: nil, + }, } for i, test := range table { @@ -399,3 +415,98 @@ func TestField_Object(t *testing.T) { } } } + +// Tests children inheriting their parent's settings +func TestField_Object_Setting_Inheritance(t *testing.T) { + obj := Object(map[string]Field{ + "child": Object(map[string]Field{}), + }).Strip(false) + + input := map[string]interface{}{ + "child": map[string]interface{}{ + "grandchild": true, + }, + "another": "hi", + } + + err := obj.Validate(input) + if err != nil { + t.Errorf(err.Error()) + } + + if v, ok := input["another"]; !ok { + t.Errorf("another is missing") + } else { + if v != "hi" { + t.Error("expected hi got", v) + } + } + + if v, ok := input["child"]; !ok { + t.Errorf("child missing") + } else { + child := v.(map[string]interface{}) + if v, ok := child["grandchild"]; !ok { + t.Errorf("grandchild missing") + } else if v != true { + t.Error("grandchild expected true, got", v) + } + } + + obj = Object(map[string]Field{ + "child": Object(map[string]Field{}).Strip(true), + }).Strip(false) + + err = obj.Validate(input) + if err != nil { + t.Errorf(err.Error()) + } + + if v, ok := input["another"]; !ok { + t.Errorf("another is missing") + } else { + if v != "hi" { + t.Error("expected hi got", v) + } + } + + if v, ok := input["child"]; !ok { + t.Errorf("child missing") + } else { + child := v.(map[string]interface{}) + if _, ok := child["grandchild"]; ok { + t.Errorf("expected grandchild to be stripped, but it still exists") + } + } +} + +// Tests children inheriting their parent's settings +func TestField_Array_Setting_Inheritance(t *testing.T) { + obj := Array().Items(Object(map[string]Field{})).Strip(false) + + input := []interface{}{ + map[string]interface{}{ + "hello": "world", + }, + } + + err := obj.Validate(input) + if err != nil { + t.Errorf(err.Error()) + } + + if fmt.Sprint(input) != "[map[hello:world]]" { + t.Errorf(fmt.Sprint(input)) + } + + obj = Array().Items(Object(map[string]Field{}).Strip(true)).Strip(false) + + err = obj.Validate(input) + if err != nil { + t.Errorf(err.Error()) + } + + if fmt.Sprint(input) != "[map[]]" { + t.Errorf(fmt.Sprint(input)) + } +} diff --git a/prehandler.go b/prehandler.go index e9e9925..ba4bc68 100644 --- a/prehandler.go +++ b/prehandler.go @@ -28,14 +28,17 @@ func (r *Router) Validate(val Validate, query url.Values, body interface{}, path } } if schema.kind == KindArray { + if schema.min != nil && float64(len(queryValue)) < *schema.min { + return errMinimum + } + if schema.max != nil && float64(len(queryValue)) > *schema.max { + return errMaximum + } // sadly we have to convert to a []interface{} to simplify the validation code var intray []interface{} for _, v := range queryValue { intray = append(intray, v) } - if err := schema.Validate(intray); err != nil { - return fmt.Errorf("query validation failed for field %v: %w", field, err) - } if schema.arr != nil { for _, v := range queryValue { convertedValue, err := convert(v, *schema.arr) diff --git a/prehandler_test.go b/prehandler_test.go index f769e33..3cfcee1 100644 --- a/prehandler_test.go +++ b/prehandler_test.go @@ -220,7 +220,7 @@ func TestQueryValidation(t *testing.T) { err = r.Validate(Validate{Query: Object(test.Schema)}, query, nil, nil) - if errors.Unwrap(err) != test.Expected { + if !errors.Is(err, test.Expected) { t.Errorf("%v: expected '%v' got '%v'. input: '%v'. schema: '%v'", i, test.Expected, err, test.Input, test.Schema) } } @@ -442,7 +442,7 @@ func TestBodyValidation(t *testing.T) { err := r.Validate(Validate{Body: Object(test.Schema)}, nil, input, nil) - if errors.Unwrap(err) != test.Expected { + if !errors.Is(err, test.Expected) { t.Errorf("expected '%v' got '%v'. input: '%v'. schema: '%v'", test.Expected, err, test.Input, test.Schema) } } From bb3030142bedcb56ca822e4359a4b02ff8904ada Mon Sep 17 00:00:00 2001 From: jake Date: Fri, 19 Nov 2021 17:28:20 -0600 Subject: [PATCH 6/9] fix #10 honor strip/allow settings in query validation --- prehandler.go | 19 +++++++++++++++++++ prehandler_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/prehandler.go b/prehandler.go index ba4bc68..7e72919 100644 --- a/prehandler.go +++ b/prehandler.go @@ -9,6 +9,25 @@ import ( // Validate checks the spec against the inputs and returns an error if it finds one. func (r *Router) Validate(val Validate, query url.Values, body interface{}, path map[string]string) error { if val.Query.kind == KindObject { // not sure how any other type makes sense + + // reject unknown values + if (val.Query.unknown == nil && r.allowUnknown == false) || !val.Query.isAllowUnknown() { + for key := range query { + if _, ok := val.Query.obj[key]; !ok { + return fmt.Errorf("unexpected query parameter %s: %w", key, errUnknown) + } + } + } + + // strip unknown values + if (val.Query.strip == nil && r.stripUnknown == true) || val.Query.isStripUnknown() { + for key := range query { + if _, ok := val.Query.obj[key]; !ok { + delete(query, key) + } + } + } + for field, schema := range val.Query.obj { // query values are always strings, so we must try to convert queryValue := query[field] diff --git a/prehandler_test.go b/prehandler_test.go index 3cfcee1..df46d30 100644 --- a/prehandler_test.go +++ b/prehandler_test.go @@ -586,7 +586,7 @@ func TestPathValidation(t *testing.T) { } } -func TestStrip(t *testing.T) { +func TestStrip_Body(t *testing.T) { r := NewRouter("", "", &TestAdapter{}, option.StripUnknown(true)) var input interface{} input = map[string]interface{}{ @@ -617,7 +617,7 @@ func TestStrip(t *testing.T) { } } -func TestUnknown(t *testing.T) { +func TestUnknown_Body(t *testing.T) { r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(true)) var input interface{} input = map[string]interface{}{ @@ -639,3 +639,45 @@ func TestUnknown(t *testing.T) { t.Errorf("Expected error") } } + +func TestUnknown_Query(t *testing.T) { + r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(false)) + query := url.Values{"unknown": []string{"value"}} + spec := Object(map[string]Field{}) + + err := r.Validate(Validate{Query: spec}, query, nil, nil) + if !errors.Is(err, errUnknown) { + t.Errorf("Expected '%s' but got '%s'", errUnknown, err) + } + + spec = spec.Unknown(true) + err = r.Validate(Validate{Query: spec}, query, nil, nil) + if err != nil { + t.Error("Unexpected error", err) + } +} + +func TestStrip_Query(t *testing.T) { + r := NewRouter("", "", &TestAdapter{}) + query := url.Values{"unknown": []string{"value"}} + spec := Object(map[string]Field{}).Strip(false) + + err := r.Validate(Validate{Query: spec}, query, nil, nil) + if err != nil { + t.Error("Unexpected error", err) + } + + if _, ok := query["unknown"]; !ok { + t.Error("Expected the value to not have been stripped") + } + + spec = spec.Strip(true) + err = r.Validate(Validate{Query: spec}, query, nil, nil) + if err != nil { + t.Error("Unexpected error", err) + } + + if _, ok := query["unknown"]; ok { + t.Error("Expected the value to have been stripped") + } +} From c0d097c885e9bc1c2aa86587407bd840e80b26f3 Mon Sep 17 00:00:00 2001 From: jake Date: Fri, 19 Nov 2021 17:36:12 -0600 Subject: [PATCH 7/9] fix #8 body is always required when set It's confusing and error-prone if the handler gets called when the body is nil. --- prehandler.go | 2 ++ prehandler_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/prehandler.go b/prehandler.go index 7e72919..5d6db8f 100644 --- a/prehandler.go +++ b/prehandler.go @@ -90,6 +90,8 @@ func (r *Router) Validate(val Validate, query url.Values, body interface{}, path if f.unknown == nil { f = f.Unknown(r.allowUnknown) } + // ensure Required() since it's confusing and error-prone otherwise + f = f.Required() if err := f.Validate(body); err != nil { return err } diff --git a/prehandler_test.go b/prehandler_test.go index df46d30..4809d85 100644 --- a/prehandler_test.go +++ b/prehandler_test.go @@ -681,3 +681,13 @@ func TestStrip_Query(t *testing.T) { t.Error("Expected the value to have been stripped") } } + +func Test_BodyValidateRequiredAutomatically(t *testing.T) { + r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(false)) + + err := r.Validate(Validate{Body: Object(map[string]Field{})}, nil, nil, nil) + + if !errors.Is(err, errRequired) { + t.Error("Expected errRequired got", err) + } +} From 290612ee9833db2418d65c90ee2e99cb253b2d5e Mon Sep 17 00:00:00 2001 From: jake Date: Sun, 21 Nov 2021 13:59:46 -0600 Subject: [PATCH 8/9] add date and datetime formats along with validation --- adapters/gin-adapter/example/main.go | 2 ++ field.go | 53 +++++++++++++++++++++++++++- field_test.go | 29 +++++++++++++++ swagger.go | 2 ++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/adapters/gin-adapter/example/main.go b/adapters/gin-adapter/example/main.go index cae3908..a3c0819 100644 --- a/adapters/gin-adapter/example/main.go +++ b/adapters/gin-adapter/example/main.go @@ -53,6 +53,8 @@ var Routes = []crud.Spec{{ Body: crud.Object(map[string]crud.Field{ "name": crud.String().Required().Example("Bob"), "arrayMatey": crud.Array().Items(crud.Number()), + "date-time": crud.DateTime(), + "date": crud.Date(), }), }, Responses: map[string]crud.Response{ diff --git a/field.go b/field.go index a1def38..991d446 100644 --- a/field.go +++ b/field.go @@ -4,11 +4,13 @@ import ( "fmt" "reflect" "strings" + "time" ) // Field allows specification of swagger or json schema types using the builder pattern. type Field struct { kind string + format string obj map[string]Field max *float64 min *float64 @@ -108,6 +110,18 @@ func (f *Field) Validate(value interface{}) error { if f.min != nil && len(v) < int(*f.min) { return errMinimum } + switch f.format { + case FormatDateTime: + _, err := time.Parse(time.RFC3339, v) + if err != nil { + return err + } + case FormatDate: + _, err := time.Parse(fullDate, v) + if err != nil { + return err + } + } case bool: if f.kind != KindBoolean { return errWrongType @@ -253,6 +267,19 @@ func String() Field { return Field{kind: KindString} } +// DateTime creates a field with dateTime type +func DateTime() Field { + return Field{kind: KindString, format: FormatDateTime} +} + +// https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14 +const fullDate = "2006-01-02" + +// Date creates a field with date type +func Date() Field { + return Field{kind: KindString, format: FormatDate} +} + // Boolean creates a field with boolean type func Boolean() Field { return Field{kind: KindBoolean} @@ -367,6 +394,18 @@ func (f Field) Items(item Field) Field { return f } +const ( + FormatDate = "date" + FormatDateTime = "dateTime" +) + +// Format is used to set custom format types. Note that formats with special +// validation in this library also have their own constructor. See DateTime for example. +func (f Field) Format(format string) Field { + f.format = format + return f +} + // Allow lets you break rules // For example, String().Required() excludes "", unless you Allow("") func (f Field) Allow(values ...interface{}) Field { @@ -432,7 +471,8 @@ func (f *Field) ToSwaggerParameters(in string) (parameters []Parameter) { return } -// ToJsonSchema transforms a field into a JsonSchema. +// ToJsonSchema transforms a field into a Swagger Schema. +// TODO this is an extension of JsonSchema, rename in v2 ToSchema() Schema func (f *Field) ToJsonSchema() JsonSchema { schema := JsonSchema{ Type: f.kind, @@ -449,10 +489,21 @@ func (f *Field) ToJsonSchema() JsonSchema { for name, field := range f.obj { prop := JsonSchema{ Type: field.kind, + Format: field.format, Example: field.example, Description: field.description, Default: field._default, } + if field.example == nil { + if field.kind == KindString { + switch field.format { + case FormatDateTime: + prop.Example = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local).Format(time.RFC3339) + case FormatDate: + prop.Example = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local).Format(fullDate) + } + } + } if field.required != nil && *field.required { schema.Required = append(schema.Required, name) } diff --git a/field_test.go b/field_test.go index d54e33b..2c2cf7c 100644 --- a/field_test.go +++ b/field_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "testing" + "time" ) func TestField_Max_Panic(t *testing.T) { @@ -510,3 +511,31 @@ func TestField_Array_Setting_Inheritance(t *testing.T) { t.Errorf(fmt.Sprint(input)) } } + +func TestField_Validate_DateTime(t *testing.T) { + dt := DateTime() + err := dt.Validate("q") + switch v := err.(type) { + case *time.ParseError: + default: + t.Errorf("Expected a ParseError, got %s", v) + } + + if err = dt.Validate(time.Now().Format(time.RFC3339)); err != nil { + t.Errorf("Expected no error, got %s", err) + } +} + +func TestField_Validate_Date(t *testing.T) { + dt := Date() + err := dt.Validate("q") + switch v := err.(type) { + case *time.ParseError: + default: + t.Errorf("Expected a ParseError, got %s", v) + } + + if err = dt.Validate(time.Now().Format(fullDate)); err != nil { + t.Errorf("Expected no error, got %s", err) + } +} diff --git a/swagger.go b/swagger.go index 7b0bffa..98c256b 100644 --- a/swagger.go +++ b/swagger.go @@ -16,6 +16,7 @@ type Info struct { type JsonSchema struct { Type string `json:"type,omitempty"` + Format string `json:"format,omitempty"` Properties map[string]JsonSchema `json:"properties,omitempty"` Items *JsonSchema `json:"items,omitempty"` Required []string `json:"required,omitempty"` @@ -48,6 +49,7 @@ type Parameter struct { In string `json:"in"` Name string `json:"name"` + // one of path, query, header, body, or form Type string `json:"type,omitempty"` Schema *Ref `json:"schema,omitempty"` From 00d15b81d61100c136c8a621a0b664013a3ef636 Mon Sep 17 00:00:00 2001 From: jake Date: Wed, 1 Dec 2021 09:41:50 -0600 Subject: [PATCH 9/9] allow float64 for integer fields due to JSON bodies --- field.go | 7 ++++++- field_test.go | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/field.go b/field.go index 991d446..f221563 100644 --- a/field.go +++ b/field.go @@ -88,7 +88,12 @@ func (f *Field) Validate(value interface{}) error { return errMinimum } case float64: - if f.kind != KindNumber { + if f.kind == KindInteger { + // since JSON is unmarshalled as float64 always + if float64(int(v)) != v { + return errWrongType + } + } else if f.kind != KindNumber { return errWrongType } if f.max != nil && v > *f.max { diff --git a/field_test.go b/field_test.go index 2c2cf7c..9b03432 100644 --- a/field_test.go +++ b/field_test.go @@ -113,6 +113,16 @@ func TestField_Integer(t *testing.T) { Input: 7.2, Expected: errWrongType, }, + { + Field: Integer(), + Input: "7", + Expected: errWrongType, + }, + { + Field: Integer(), + Input: 7., // Allowed since body will contain float64 with JSON + Expected: nil, + }, { Field: Integer(), Input: nil,