diff --git a/graphql/handler.go b/graphql/handler.go index c94fcfa6..f0bce1f2 100644 --- a/graphql/handler.go +++ b/graphql/handler.go @@ -9,7 +9,6 @@ import ( "github.com/graphql-go/graphql" "github.com/rs/rest-layer/resource" - "github.com/rs/rest-layer/schema" ) // Handler is a net/http compatible handler used to serve the configured GraphQL @@ -21,7 +20,7 @@ type Handler struct { // NewHandler creates an new GraphQL API HTTP handler with the specified // resource index. func NewHandler(i resource.Index) (*Handler, error) { - if c, ok := i.(schema.Compiler); ok { + if c, ok := i.(resource.Compiler); ok { if err := c.Compile(); err != nil { return nil, err } diff --git a/graphql/query.go b/graphql/query.go index c5a1c26e..5e556af6 100644 --- a/graphql/query.go +++ b/graphql/query.go @@ -9,12 +9,11 @@ import ( "github.com/graphql-go/graphql" "github.com/rs/rest-layer/resource" - "github.com/rs/rest-layer/schema" ) func newRootQuery(idx resource.Index) *graphql.Object { t := types{} - if c, ok := idx.(schema.Compiler); ok { + if c, ok := idx.(resource.Compiler); ok { if err := c.Compile(); err != nil { log.Fatal(err) } diff --git a/resource/index.go b/resource/index.go index b6477d84..746f0e65 100644 --- a/resource/index.go +++ b/resource/index.go @@ -14,17 +14,25 @@ import ( type Index interface { // Bind a new resource at the "name" endpoint Bind(name string, s schema.Schema, h Storer, c Conf) *Resource - // GetResource retrives a given resource by it's path. - // For instance if a resource user has a sub-resource posts, - // a users.posts path can be use to retrieve the posts resource. + // GetResource retrieves a given resource by it's path. For instance if a + // resource "user" has a sub-resource "posts", a "users.posts" path can be + // use to retrieve the posts resource. // - // If a parent is given and the path starts with a dot, the lookup is started at the - // parent's location instead of root's. + // If a parent is given and the path starts with a dot, the lookup is + // started at the parent's location instead of root's. GetResource(path string, parent *Resource) (*Resource, bool) - // GetResources returns first level resources + // GetResources returns first level resources. GetResources() []*Resource } +// Compiler is an optional interface for Index that's task is to prepare the +// index for usage. When the method exists, it's automatically called by +// rest.NewHandler(). When the resource package is used without the rest +// package, it's the user's responsibilty to call this method. +type Compiler interface { + Compile() error +} + // index is the root of the resource graph. type index struct { resources subResources @@ -38,26 +46,34 @@ func NewIndex() Index { } // Bind a resource at the specified endpoint name. -func (r *index) Bind(name string, s schema.Schema, h Storer, c Conf) *Resource { - assertNotBound(name, r.resources, nil) +func (i *index) Bind(name string, s schema.Schema, h Storer, c Conf) *Resource { + assertNotBound(name, i.resources, nil) sr := new(name, s, h, c) - r.resources.add(sr) + i.resources.add(sr) return sr } // Compile the resource graph and report any error. -func (r *index) Compile() error { - return compileResourceGraph(r.resources) +func (i *index) Compile() error { + for _, r := range i.resources { + if err := r.Compile(refChecker{i}); err != nil { + sep := "." + if err.Error()[0] == ':' { + sep = "" + } + return fmt.Errorf("%s%s%s", r.name, sep, err) + } + } + return nil } -// GetResource retrives a given resource by it's path. -// For instance if a resource user has a sub-resource posts, -// a users.posts path can be use to retrieve the posts resource. +// GetResource retrieves a given resource by it's path. For instance if a resource "user" has a sub-resource "posts", a +// "users.posts" path can be use to retrieve the posts resource. // // If a parent is given and the path starts with a dot, the lookup is started at the // parent's location instead of root's. -func (r *index) GetResource(path string, parent *Resource) (*Resource, bool) { - resources := r.resources +func (i *index) GetResource(path string, parent *Resource) (*Resource, bool) { + resources := i.resources if len(path) > 0 && path[0] == '.' { if parent == nil { // If field starts with a dot and no parent is given, fail the lookup. @@ -83,21 +99,42 @@ func (r *index) GetResource(path string, parent *Resource) (*Resource, bool) { } // GetResources returns first level resources. -func (r *index) GetResources() []*Resource { - return r.resources +func (i *index) GetResources() []*Resource { + return i.resources } -func compileResourceGraph(resources subResources) error { - for _, r := range resources { - if err := r.Compile(); err != nil { - sep := "." - if err.Error()[0] == ':' { - sep = "" +// resourceLookup provides a wrapper for Index that implements the schema.ReferenceChecker interface. +type refChecker struct { + index Index +} + +// ReferenceChecker implements the schema.ReferenceChecker interface. +func (rc refChecker) ReferenceChecker(path string) schema.FieldValidator { + rsc, exists := rc.index.GetResource(path, nil) + if !exists { + return nil + } + validator := rsc.Schema().Fields["id"].Validator + + return schema.FieldValidatorFunc(func(value interface{}) (interface{}, error) { + var id interface{} + var err error + + if validator != nil { + id, err = validator.Validate(value) + if err != nil { + return nil, err } - return fmt.Errorf("%s%s%s", r.name, sep, err) + } else { + id = value } - } - return nil + + _, err = rsc.Get(context.TODO(), id) + if err != nil { + return nil, err + } + return id, nil + }) } // assertNotBound asserts a given resource name is not already bound. diff --git a/resource/index_test.go b/resource/index_test.go index cca03af6..7d404e3b 100644 --- a/resource/index_test.go +++ b/resource/index_test.go @@ -30,35 +30,64 @@ func TestIndexBind(t *testing.T) { func TestIndexCompile(t *testing.T) { r, ok := NewIndex().(*index) - if assert.True(t, ok) { - s := schema.Schema{Fields: schema.Fields{"f": {}}} - r.Bind("foo", s, nil, DefaultConf) - assert.NoError(t, r.Compile()) + if !assert.True(t, ok) { + return } + s := schema.Schema{Fields: schema.Fields{"f": {}}} + r.Bind("foo", s, nil, DefaultConf) + assert.NoError(t, r.Compile()) } func TestIndexCompileError(t *testing.T) { r, ok := NewIndex().(*index) - if assert.True(t, ok) { - s := schema.Schema{ - Fields: schema.Fields{ - "f": {Validator: schema.String{Regexp: "["}}, - }, - } - r.Bind("foo", s, nil, DefaultConf) - assert.Error(t, r.Compile()) + if !assert.True(t, ok) { + return + } + s := schema.Schema{ + Fields: schema.Fields{ + "f": {Validator: schema.String{Regexp: "["}}, + }, } + r.Bind("foo", s, nil, DefaultConf) + assert.Error(t, r.Compile()) } func TestIndexCompileSubError(t *testing.T) { r, ok := NewIndex().(*index) - if assert.True(t, ok) { - foo := r.Bind("foo", schema.Schema{Fields: schema.Fields{"f": {}}}, nil, DefaultConf) - bar := foo.Bind("bar", "f", schema.Schema{Fields: schema.Fields{"f": {}}}, nil, DefaultConf) - s := schema.Schema{Fields: schema.Fields{"f": {Validator: &schema.String{Regexp: "["}}}} - bar.Bind("baz", "f", s, nil, DefaultConf) - assert.EqualError(t, r.Compile(), "foo.bar.baz: schema compilation error: f: invalid regexp: error parsing regexp: missing closing ]: `[`") + if !assert.True(t, ok) { + return + } + foo := r.Bind("foo", schema.Schema{Fields: schema.Fields{"f": {}}}, nil, DefaultConf) + bar := foo.Bind("bar", "f", schema.Schema{Fields: schema.Fields{"f": {}}}, nil, DefaultConf) + s := schema.Schema{Fields: schema.Fields{"f": {Validator: &schema.String{Regexp: "["}}}} + bar.Bind("baz", "f", s, nil, DefaultConf) + assert.EqualError(t, r.Compile(), "foo.bar.baz: schema compilation error: f: invalid regexp: error parsing regexp: missing closing ]: `[`") +} + +func TestIndexCompileReferenceChecker(t *testing.T) { + i, ok := NewIndex().(*index) + if !assert.True(t, ok) { + return + } + + i.Bind("b", schema.Schema{Fields: schema.Fields{"id": {}}}, nil, DefaultConf) + i.Bind("a", schema.Schema{Fields: schema.Fields{"ref": { + Validator: &schema.Reference{Path: "b"}, + }}}, nil, DefaultConf) + assert.NoError(t, i.Compile()) +} + +func TestIndexCompileReferenceCheckerError(t *testing.T) { + i, ok := NewIndex().(*index) + if !assert.True(t, ok) { + return } + + i.Bind("b", schema.Schema{Fields: schema.Fields{"id": {}}}, nil, DefaultConf) + i.Bind("a", schema.Schema{Fields: schema.Fields{"ref": { + Validator: &schema.Reference{Path: "c"}, + }}}, nil, DefaultConf) + assert.Error(t, i.Compile()) } func TestIndexGetResource(t *testing.T) { diff --git a/resource/resource.go b/resource/resource.go index f2661982..d03bd57d 100644 --- a/resource/resource.go +++ b/resource/resource.go @@ -113,15 +113,15 @@ func (r *Resource) ParentField() string { } // Compile the resource graph and report any error. -func (r *Resource) Compile() error { +func (r *Resource) Compile(rc schema.ReferenceChecker) error { // Compile schema and panic on any compilation error. if c, ok := r.validator.Validator.(schema.Compiler); ok { - if err := c.Compile(); err != nil { + if err := c.Compile(rc); err != nil { return fmt.Errorf(": schema compilation error: %s", err) } } for _, r := range r.resources { - if err := r.Compile(); err != nil { + if err := r.Compile(rc); err != nil { if err.Error()[0] == ':' { // Check if I'm the direct ancestor of the raised sub-error. return fmt.Errorf("%s%s", r.name, err) diff --git a/rest/handler.go b/rest/handler.go index bbb2b433..5eb3bcef 100644 --- a/rest/handler.go +++ b/rest/handler.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/rs/rest-layer/resource" - "github.com/rs/rest-layer/schema" ) // Handler is a net/http compatible handler used to serve the configured REST @@ -27,7 +26,7 @@ type methodHandler func(ctx context.Context, r *http.Request, route *RouteMatch) // NewHandler creates an new REST API HTTP handler with the specified resource // index. func NewHandler(i resource.Index) (*Handler, error) { - if c, ok := i.(schema.Compiler); ok { + if c, ok := i.(resource.Compiler); ok { if err := c.Compile(); err != nil { return nil, err } diff --git a/rest/method_item_patch.go b/rest/method_item_patch.go index ea7fa80c..0b0e25a0 100644 --- a/rest/method_item_patch.go +++ b/rest/method_item_patch.go @@ -45,11 +45,6 @@ func itemPatch(ctx context.Context, r *http.Request, route *RouteMatch) (status if len(errs) > 0 { return 422, nil, &Error{422, "Document contains error(s)", errs} } - // Check that fields with the Reference validator reference an existing - // object. - if e = checkReferences(ctx, doc, rsrc.Validator()); e != nil { - return e.Code, nil, e - } if id, found := doc["id"]; found && id != original.ID { return 422, nil, &Error{422, "Cannot change document ID", nil} } diff --git a/rest/method_item_put.go b/rest/method_item_put.go index 8d9973dc..f7414e0d 100644 --- a/rest/method_item_put.go +++ b/rest/method_item_put.go @@ -69,10 +69,6 @@ func itemPut(ctx context.Context, r *http.Request, route *RouteMatch) (status in if len(errs) > 0 { return 422, nil, &Error{422, "Document contains error(s)", errs} } - // Check that fields with the Reference validator reference an existing object. - if err := checkReferences(ctx, doc, rsrc.Validator()); err != nil { - return err.Code, nil, err - } if original != nil { if id, found := doc["id"]; found && id != original.ID { return 422, nil, &Error{422, "Cannot change document ID", nil} diff --git a/rest/method_post.go b/rest/method_post.go index 2c804665..bda99efe 100644 --- a/rest/method_post.go +++ b/rest/method_post.go @@ -29,12 +29,6 @@ func listPost(ctx context.Context, r *http.Request, route *RouteMatch) (status i if len(errs) > 0 { return 422, nil, &Error{422, "Document contains error(s)", errs} } - // Check that fields with the Reference validator reference an existing - // object. - if err := checkReferences(ctx, doc, rsrc.Validator()); err != nil { - e = NewError(err) - return e.Code, nil, e - } item, err := resource.NewItem(doc) if err != nil { e = NewError(err) diff --git a/rest/method_post_test.go b/rest/method_post_test.go index 85d1b5ab..08123a41 100644 --- a/rest/method_post_test.go +++ b/rest/method_post_test.go @@ -1,410 +1,340 @@ -package rest +package rest_test import ( "bytes" "context" - "io/ioutil" "net/http" - "net/http/httptest" - "net/url" "testing" "github.com/rs/rest-layer-mem" "github.com/rs/rest-layer/resource" + "github.com/rs/rest-layer/rest" "github.com/rs/rest-layer/schema" "github.com/rs/rest-layer/schema/query" "github.com/stretchr/testify/assert" ) func TestHandlerPostList(t *testing.T) { - i := resource.NewIndex() - s := mem.NewHandler() - i.Bind("foo", schema.Schema{Fields: schema.Fields{ - "id": {OnInit: func(ctx context.Context, v interface{}) interface{} { return "1" }}, - "foo": {}, - "bar": {}}}, s, resource.DefaultConf) - h, _ := NewHandler(i) - w := httptest.NewRecorder() - r, _ := http.NewRequest("POST", "/foo", bytes.NewBufferString(`{"foo": "bar"}`)) - h.ServeHTTP(w, r) - assert.Equal(t, 201, w.Code) - b, _ := ioutil.ReadAll(w.Body) - assert.Equal(t, `{"foo":"bar","id":"1"}`, string(b)) - lkp := resource.NewLookupWithQuery(query.Query{query.Equal{Field: "id", Value: "1"}}) - l, err := s.Find(context.TODO(), lkp, 0, 1) - assert.NoError(t, err) - if assert.Len(t, l.Items, 1) { - assert.Equal(t, map[string]interface{}{"id": "1", "foo": "bar"}, l.Items[0].Payload) - } -} - -func TestHandlerPostListBadPayload(t *testing.T) { - index := resource.NewIndex() - test := index.Bind("test", schema.Schema{}, nil, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString("{invalid json")) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + tests := map[string]requestTest{ + "OK": { + Init: func() *requestTestVars { + i := resource.NewIndex() + s := mem.NewHandler() + i.Bind("foo", schema.Schema{Fields: schema.Fields{ + "id": {OnInit: func(ctx context.Context, v interface{}) interface{} { return "1" }}, + "foo": {}, + "bar": {}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: i, Storers: map[string]resource.Storer{"foo": s}} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/foo", bytes.NewBufferString(`{"foo": "bar"}`)) + }, + ResponseCode: 201, + ResponseBody: `{"foo":"bar","id":"1"}`, + ExtraTest: func(t *testing.T, vars *requestTestVars) { + lkp := resource.NewLookupWithQuery(query.Query{query.Equal{Field: "id", Value: "1"}}) + s, ok := vars.Storers["foo"] + if !assert.True(t, ok) { + return + } + l, err := s.Find(context.TODO(), lkp, 0, 1) + assert.NoError(t, err) + if assert.Len(t, l.Items, 1) { + assert.Equal(t, map[string]interface{}{"id": "1", "foo": "bar"}, l.Items[0].Payload) + } }, }, - } - status, headers, body := listPost(context.TODO(), r, rm) - assert.Equal(t, http.StatusBadRequest, status) - assert.Nil(t, headers) - if assert.IsType(t, body, &Error{}) { - err := body.(*Error) - assert.Equal(t, http.StatusBadRequest, err.Code) - assert.Equal(t, "Malformed body: invalid character 'i' looking for beginning of object key string", err.Message) - } -} - -func TestHandlerPostListInvalIDLookupFields(t *testing.T) { - index := resource.NewIndex() - test := index.Bind("test", schema.Schema{}, nil, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString("{}")) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + "BadPayload": { + Init: func() *requestTestVars { + index := resource.NewIndex() + index.Bind("test", schema.Schema{}, nil, resource.DefaultConf) + return &requestTestVars{Index: index} }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test", bytes.NewBufferString(`{invalid json`)) + }, + ResponseCode: http.StatusBadRequest, + ResponseBody: `{ + "code": 400, + "message": "Malformed body: invalid character 'i' looking for beginning of object key string" + }`, }, - Params: url.Values{ - "fields": []string{"invalid"}, + "InvalIDLookupFields": { + Init: func() *requestTestVars { + index := resource.NewIndex() + index.Bind("test", schema.Schema{}, nil, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test?fields=invalid", bytes.NewBufferString(`{}`)) + }, + ResponseCode: http.StatusUnprocessableEntity, + ResponseBody: `{ + "code": 422, + "message": "Invalid ` + "`fields`" + ` parameter: invalid: unknown field" + }`, }, - } - status, headers, body := listPost(context.TODO(), r, rm) - assert.Equal(t, 422, status) - assert.Nil(t, headers) - if assert.IsType(t, body, &Error{}) { - err := body.(*Error) - assert.Equal(t, 422, err.Code) - assert.Equal(t, "Invalid `fields` parameter: invalid: unknown field", err.Message) - } -} - -func TestHandlerPostListDup(t *testing.T) { - index := resource.NewIndex() - s := mem.NewHandler() - s.Insert(context.TODO(), []*resource.Item{ - {ID: "1", Payload: map[string]interface{}{"id": "1", "foo": "bar"}}, - {ID: "2", Payload: map[string]interface{}{"id": "2", "foo": "bar"}}, - {ID: "3", Payload: map[string]interface{}{"id": "3", "foo": "bar"}}, - }) - test := index.Bind("test", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "2", "foo": "baz"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + "Dup": { + Init: func() *requestTestVars { + index := resource.NewIndex() + s := mem.NewHandler() + s.Insert(context.TODO(), []*resource.Item{ + {ID: "1", Payload: map[string]interface{}{"id": "1", "foo": "bar"}}, + {ID: "2", Payload: map[string]interface{}{"id": "2", "foo": "bar"}}, + {ID: "3", Payload: map[string]interface{}{"id": "3", "foo": "bar"}}, + }) + index.Bind("test", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foo": {}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "2", "foo": "baz"}`)) }, + ResponseCode: http.StatusConflict, + ResponseBody: `{"code":409,"message":"Conflict"}`, }, - } - status, _, _ := listPost(context.TODO(), r, rm) - assert.Equal(t, http.StatusConflict, status) -} - -func TestHandlerPostListNew(t *testing.T) { - index := resource.NewIndex() - s := mem.NewHandler() - test := index.Bind("test", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "2", "foo": "baz"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + "New": { + Init: func() *requestTestVars { + index := resource.NewIndex() + s := mem.NewHandler() + index.Bind("test", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foo": {}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "2", "foo": "baz"}`)) + }, + ResponseCode: http.StatusCreated, + ResponseBody: `{"id":"2","foo":"baz"}`, + ResponseHeader: http.Header{"Content-Location": []string{"/test/2"}}, }, - } - status, headers, body := listPost(context.TODO(), r, rm) - assert.Equal(t, http.StatusCreated, status) - assert.Equal(t, http.Header{"Content-Location": []string{"/test/2"}}, headers) - if assert.IsType(t, body, &resource.Item{}) { - i := body.(*resource.Item) - assert.Equal(t, "2", i.ID) - assert.Equal(t, map[string]interface{}{"id": "2", "foo": "baz"}, i.Payload) - } -} - -func TestHandlerPostListInvalidField(t *testing.T) { - index := resource.NewIndex() - s := mem.NewHandler() - test := index.Bind("test", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "2", "foo": "baz"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + "InvalidField": { + Init: func() *requestTestVars { + index := resource.NewIndex() + s := mem.NewHandler() + index.Bind("test", schema.Schema{Fields: schema.Fields{ + "id": {}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "2", "foo": "baz"}`)) }, + ResponseCode: http.StatusUnprocessableEntity, + ResponseBody: `{ + "code": 422, + "message": "Document contains error(s)", + "issues": {"foo": ["invalid field"]} + }`, }, - } - status, headers, body := listPost(context.TODO(), r, rm) - assert.Equal(t, 422, status) - assert.Nil(t, headers) - if assert.IsType(t, body, &Error{}) { - err := body.(*Error) - assert.Equal(t, 422, err.Code) - assert.Equal(t, "Document contains error(s)", err.Message) - assert.Equal(t, map[string][]interface{}{ - "foo": []interface{}{"invalid field"}}, err.Issues) - } -} - -func TestHandlerPostListMissingID(t *testing.T) { - index := resource.NewIndex() - test := index.Bind("test", schema.Schema{Fields: schema.Fields{"id": {}}}, nil, resource.Conf{ - AllowedModes: []resource.Mode{resource.Replace}, - }) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString(`{}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + "MissingID": { + Init: func() *requestTestVars { + index := resource.NewIndex() + s := mem.NewHandler() + index.Bind("test", schema.Schema{Fields: schema.Fields{ + "id": {}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test", bytes.NewBufferString(`{}`)) }, + // FIXME: The HTTP 520 code is usually used for protocol errors, and + // seems unaprporiate. This should most likely be a 422 error. + ResponseCode: 520, + ResponseBody: `{ + "code": 520, + "message": "Missing ID field" + }`, }, - } - status, headers, body := listPost(context.TODO(), r, rm) - assert.Equal(t, 520, status) - assert.Nil(t, headers) - if assert.IsType(t, body, &Error{}) { - err := body.(*Error) - assert.Equal(t, 520, err.Code) - assert.Equal(t, "Missing ID field", err.Message) - } -} - -func TestHandlerPostListNoStorage(t *testing.T) { - index := resource.NewIndex() - test := index.Bind("test", schema.Schema{Fields: schema.Fields{"id": {}}}, nil, resource.Conf{ - AllowedModes: []resource.Mode{resource.Replace}, - }) - r, _ := http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id": "1"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "test", - Resource: test, + "NoStorage": { + // FIXME: For NoStorage, it's probably better to error early (during Bind). + Init: func() *requestTestVars { + index := resource.NewIndex() + index.Bind("test", schema.Schema{Fields: schema.Fields{ + "id": {}, + }}, nil, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/test", bytes.NewBufferString(`{"id":1}`)) }, + ResponseCode: http.StatusNotImplemented, + ResponseBody: `{ + "code": 501, + "message": "No Storage Defined" + }`, }, - } - status, headers, body := listPost(context.TODO(), r, rm) - assert.Equal(t, http.StatusNotImplemented, status) - assert.Nil(t, headers) - if assert.IsType(t, body, &Error{}) { - err := body.(*Error) - assert.Equal(t, http.StatusNotImplemented, err.Code) - assert.Equal(t, "No Storage Defined", err.Message) - } -} - -func TestHandlerPostListWithReferenceNoRouter(t *testing.T) { - s := mem.NewHandler() - index := resource.NewIndex() - index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {Validator: &schema.Reference{Path: "foo"}}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "nonexisting"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, + "WithReferenceNotFound": { + Init: func() *requestTestVars { + s := mem.NewHandler() + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foo": {Validator: &schema.Reference{Path: "foo"}}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "nonexisting"}`)) }, + ResponseCode: http.StatusUnprocessableEntity, + ResponseBody: `{ + "code": 422, + "message": "Document contains error(s)", + "issues": {"foo": ["Not Found"]} + }`, }, - } - status, _, body := listPost(context.TODO(), r, rm) - assert.Equal(t, http.StatusInternalServerError, status) - if assert.IsType(t, &Error{}, body) { - err := body.(*Error) - assert.Equal(t, http.StatusInternalServerError, err.Code) - assert.Equal(t, "Router not available in context", err.Message) - } -} - -func TestHandlerPostListWithInvalidReference(t *testing.T) { - s := mem.NewHandler() - index := resource.NewIndex() - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {Validator: &schema.Reference{Path: "invalid"}}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "1"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, + "WithReferenceNoStorage": { + // FIXME: For NoStorage, it's probably better to error early (during Bind). + Init: func() *requestTestVars { + s := mem.NewHandler() + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, nil, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foo": {Validator: &schema.Reference{Path: "foo"}}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "1"}`)) + }, + ResponseCode: http.StatusUnprocessableEntity, + ResponseBody: `{ + "code": 422, + "message": "Document contains error(s)", + "issues": {"foo": ["No Storage Defined"]} + }`, }, - } - ctx := contextWithIndex(context.Background(), index) - status, _, body := listPost(ctx, r, rm) - assert.Equal(t, http.StatusInternalServerError, status) - if assert.IsType(t, &Error{}, body) { - err := body.(*Error) - assert.Equal(t, http.StatusInternalServerError, err.Code) - assert.Equal(t, "Invalid resource reference for field `foo': invalid", err.Message) - } -} - -func TestHandlerPostListWithReferenceOtherError(t *testing.T) { - s := mem.NewHandler() - index := resource.NewIndex() - index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, nil, resource.DefaultConf) - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {Validator: &schema.Reference{Path: "foo"}}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "1"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, + "WithReference": { + Init: func() *requestTestVars { + s := mem.NewHandler() + s.Insert(context.Background(), []*resource.Item{{ID: "ref", Payload: map[string]interface{}{"id": "ref"}}}) + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foo": {Validator: &schema.Reference{Path: "foo"}}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "ref"}`)) + }, + ResponseCode: http.StatusCreated, + ResponseBody: `{"id": "1", "foo": "ref"}`, }, - } - ctx := contextWithIndex(context.Background(), index) - status, _, body := listPost(ctx, r, rm) - assert.Equal(t, http.StatusInternalServerError, status) - if assert.IsType(t, &Error{}, body) { - err := body.(*Error) - assert.Equal(t, http.StatusInternalServerError, err.Code) - assert.Equal(t, "Error fetching resource reference for field `foo': No Storage Defined", err.Message) - } -} - -func TestHandlerPostListWithReferenceNotFound(t *testing.T) { - s := mem.NewHandler() - index := resource.NewIndex() - index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {Validator: &schema.Reference{Path: "foo"}}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "nonexisting"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, + "WithSubSchemaReference": { + Init: func() *requestTestVars { + s := mem.NewHandler() + s.Insert(context.Background(), []*resource.Item{{ID: "ref", Payload: map[string]interface{}{"id": "ref"}}}) + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "sub": {Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.Reference{Path: "foo"}}, + }}}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "sub": {"foo": "ref"}}`)) }, + ResponseCode: http.StatusCreated, + ResponseBody: `{"id": "1", "sub": {"foo": "ref"}}`, }, - } - ctx := contextWithIndex(context.Background(), index) - status, _, body := listPost(ctx, r, rm) - assert.Equal(t, http.StatusNotFound, status) - if assert.IsType(t, &Error{}, body) { - err := body.(*Error) - assert.Equal(t, http.StatusNotFound, err.Code) - assert.Equal(t, "Resource reference not found for field `foo'", err.Message) - } -} - -func TestHandlerPostListWithReference(t *testing.T) { - s := mem.NewHandler() - s.Insert(context.Background(), []*resource.Item{{ID: "ref", Payload: map[string]interface{}{"id": "ref"}}}) - index := resource.NewIndex() - index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "foo": {Validator: &schema.Reference{Path: "foo"}}, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foo": "ref"}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, + "WithSubSchemaObjectReference": { + Init: func() *requestTestVars { + s := mem.NewHandler() + s.Insert(context.Background(), []*resource.Item{{ID: "ref", Payload: map[string]interface{}{"id": "ref"}}}) + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "sub": {Validator: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.Reference{Path: "foo"}}, + }}}}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} + }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "sub": {"foo": "ref"}}`)) }, + ResponseCode: http.StatusCreated, + ResponseBody: `{"id": "1", "sub": {"foo": "ref"}}`, }, - } - ctx := contextWithIndex(context.Background(), index) - status, _, body := listPost(ctx, r, rm) - assert.Equal(t, http.StatusCreated, status) - if assert.IsType(t, &resource.Item{}, body) { - item := body.(*resource.Item) - assert.Equal(t, "1", item.ID) - } -} - -func TestHandlerPostListWithSubSchemaReference(t *testing.T) { - s := mem.NewHandler() - s.Insert(context.Background(), []*resource.Item{{ID: "ref", Payload: map[string]interface{}{"id": "ref"}}}) - index := resource.NewIndex() - index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "sub": { - Schema: &schema.Schema{ - Fields: schema.Fields{ - "foo": {Validator: &schema.Reference{Path: "foo"}}, - }, + "WithArraySchemaReferenceNotFound": { + Init: func() *requestTestVars { + s := mem.NewHandler() + s.Insert(context.Background(), []*resource.Item{ + {ID: "ref1", Payload: map[string]interface{}{"id": "ref1"}}, + }) + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foos": {Validator: &schema.Array{ + ValuesValidator: &schema.Reference{Path: "foo"}, + }}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foos": ["ref1", "ref2"]}`)) + }, + ResponseCode: http.StatusUnprocessableEntity, + ResponseBody: `{ + "code": 422, + "message": "Document contains error(s)", + "issues": {"foos":["invalid value at #2: Not Found"]} + }`, }, - }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "sub": {"foo": "ref"}}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, + "WithArraySchemaReference": { + Init: func() *requestTestVars { + s := mem.NewHandler() + s.Insert(context.Background(), []*resource.Item{ + {ID: "ref1", Payload: map[string]interface{}{"id": "ref1"}}, + {ID: "ref2", Payload: map[string]interface{}{"id": "ref2"}}, + }) + index := resource.NewIndex() + index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foos": {Validator: &schema.Array{ + ValuesValidator: &schema.Reference{Path: "foo"}, + }}, + }}, s, resource.DefaultConf) + return &requestTestVars{Index: index} }, + NewRequest: func() (*http.Request, error) { + return http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "foos": ["ref1", "ref2"]}`)) + }, + ResponseCode: http.StatusCreated, + ResponseBody: `{"id": "1", "foos": ["ref1", "ref2"]}`, }, } - ctx := contextWithIndex(context.Background(), index) - status, _, body := listPost(ctx, r, rm) - assert.Equal(t, http.StatusCreated, status) - if assert.IsType(t, &resource.Item{}, body) { - item := body.(*resource.Item) - assert.Equal(t, "1", item.ID) + for name, tt := range tests { + tt := tt // capture range variable. + t.Run(name, tt.Test) } } - -func TestHandlerPostListWithSubSchemaReferenceNotFound(t *testing.T) { +func TestHandlerPostListWithInvalidReference(t *testing.T) { s := mem.NewHandler() - s.Insert(context.Background(), []*resource.Item{{ID: "ref", Payload: map[string]interface{}{"id": "ref"}}}) index := resource.NewIndex() - index.Bind("foo", schema.Schema{Fields: schema.Fields{"id": {}}}, s, resource.DefaultConf) - bar := index.Bind("bar", schema.Schema{Fields: schema.Fields{ - "id": {}, - "sub": { - Schema: &schema.Schema{ - Fields: schema.Fields{ - "foo": {Validator: &schema.Reference{Path: "foo"}}, - }, - }, - }, + index.Bind("bar", schema.Schema{Fields: schema.Fields{ + "id": {}, + "foo": {Validator: &schema.Reference{Path: "invalid"}}, }}, s, resource.DefaultConf) - r, _ := http.NewRequest("POST", "/bar", bytes.NewBufferString(`{"id": "1", "sub": {"foo": "notfound"}}`)) - rm := &RouteMatch{ - ResourcePath: []*ResourcePathComponent{ - &ResourcePathComponent{ - Name: "bar", - Resource: bar, - }, - }, - } - ctx := contextWithIndex(context.Background(), index) - status, _, body := listPost(ctx, r, rm) - assert.Equal(t, http.StatusNotFound, status) - if assert.IsType(t, &Error{}, body) { - err := body.(*Error) - assert.Equal(t, http.StatusNotFound, err.Code) - assert.Equal(t, "Resource reference not found for field `foo'", err.Message) - } + + h, err := rest.NewHandler(index) + assert.Error(t, err, "bar: schema compilation error: foo: can't find resource 'invalid'", "rest.NewHandler(index)") + assert.Nil(t, h, "rest.NewHandler(index)") } diff --git a/rest/method_test.go b/rest/method_test.go new file mode 100644 index 00000000..5bab48ad --- /dev/null +++ b/rest/method_test.go @@ -0,0 +1,58 @@ +package rest_test + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rs/rest-layer/resource" + "github.com/rs/rest-layer/rest" + "github.com/stretchr/testify/assert" +) + +// requestTest is a reusable type for testing POST, PUT, PATCH or GET requests. Best used in a map, E.g.: +// go +type requestTest struct { + Init func() *requestTestVars + NewRequest func() (*http.Request, error) + ResponseCode int + ResponseHeader http.Header // Only checks provided headers, not that all headers are equal. + ResponseBody string + ExtraTest func(*testing.T, *requestTestVars) +} + +// requestTestVars provodes test runtime variables. +type requestTestVars struct { + Index resource.Index // required + Storers map[string]resource.Storer // optional: may be used by ExtraTest function +} + +// Test runs tt in parallel mode. It can be passed as a second parameter to +// Run(name, f) for the *testing.T type. +func (tt *requestTest) Test(t *testing.T) { + t.Parallel() + vars := tt.Init() + h, err := rest.NewHandler(vars.Index) + if !assert.NoError(t, err, "rest.NewHandler(vars.Index)") { + return + } + r, err := tt.NewRequest() + if !assert.NoError(t, err, "tt.NewRequest()") { + return + } + w := httptest.NewRecorder() + + h.ServeHTTP(w, r) + assert.Equal(t, tt.ResponseCode, w.Code, "h.ServeHTTP(w, r); w.Code") + headers := w.Header() + for k, v := range tt.ResponseHeader { + assert.Equal(t, v, headers[k], "h.ServeHTTP(w, r); w.Header()[%q]", k) + } + b, _ := ioutil.ReadAll(w.Body) + assert.JSONEq(t, tt.ResponseBody, string(b), "h.ServeHTTP(w, r); w.Body") + + if tt.ExtraTest != nil { + tt.ExtraTest(t, vars) + } +} diff --git a/rest/response.go b/rest/response.go index 008538ce..279ba75f 100644 --- a/rest/response.go +++ b/rest/response.go @@ -110,9 +110,6 @@ func (f DefaultResponseFormatter) FormatList(ctx context.Context, headers http.H // FormatError implements ResponseFormatter. func (f DefaultResponseFormatter) FormatError(ctx context.Context, headers http.Header, err error, skipBody bool) (context.Context, interface{}) { - oldLogger := resource.Logger - resource.Logger = nil - defer func() { resource.Logger = oldLogger }() code := 500 message := "Server Error" if err != nil { diff --git a/rest/util.go b/rest/util.go index a4c0fd31..4a7d91ce 100644 --- a/rest/util.go +++ b/rest/util.go @@ -10,7 +10,6 @@ import ( "time" "github.com/rs/rest-layer/resource" - "github.com/rs/rest-layer/schema" ) // getMethodHandler returns the method handler for a given HTTP method in item @@ -176,45 +175,6 @@ func checkIntegrityRequest(r *http.Request, original *resource.Item) *Error { return nil } -// checkReferences ensures that fields with the Reference validator reference an -// existing object. -func checkReferences(ctx context.Context, payload map[string]interface{}, s schema.Validator) *Error { - for name, value := range payload { - field := s.GetField(name) - if field == nil { - continue - } - // Check reference if validator is of type Reference. - if field.Validator != nil { - if ref, ok := field.Validator.(*schema.Reference); ok { - router, ok := IndexFromContext(ctx) - if !ok { - return &Error{500, "Router not available in context", nil} - } - rsrc, found := router.GetResource(ref.Path, nil) - if !found { - return &Error{500, fmt.Sprintf("Invalid resource reference for field `%s': %s", name, ref.Path), nil} - } - _, err := rsrc.Get(ctx, value) - if err == resource.ErrNotFound { - return &Error{404, fmt.Sprintf("Resource reference not found for field `%s'", name), nil} - } else if err != nil { - return &Error{500, fmt.Sprintf("Error fetching resource reference for field `%s': %v", name, err), nil} - } - } - } - // Check sub-schema if any. - if field.Schema != nil && value != nil { - if subPayload, ok := value.(map[string]interface{}); ok { - if err := checkReferences(ctx, subPayload, field.Schema); err != nil { - return err - } - } - } - } - return nil -} - func getReferenceResolver(ctx context.Context, r *resource.Resource) resource.ReferenceResolver { return func(path string) (*resource.Resource, error) { router, ok := IndexFromContext(ctx) diff --git a/schema/all_test.go b/schema/all_test.go index 30449927..c4d00d07 100644 --- a/schema/all_test.go +++ b/schema/all_test.go @@ -1,51 +1,104 @@ -// +build go1.7 - package schema_test import ( + "errors" "testing" "github.com/rs/rest-layer/schema" "github.com/stretchr/testify/assert" ) -type compilerTestCase struct { - Name string - Compiler schema.Compiler - Error string +type referenceCompilerTestCase struct { + Name string + Compiler schema.Compiler + ReferenceChecker schema.ReferenceChecker + Error string } -func (tc compilerTestCase) Run(t *testing.T) { +func (tc referenceCompilerTestCase) Run(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { t.Parallel() - err := tc.Compiler.Compile() + err := tc.Compiler.Compile(tc.ReferenceChecker) if tc.Error == "" { - assert.NoError(t, err) + assert.NoError(t, err, "Compiler.Compile(%v)", tc.ReferenceChecker) } else { - assert.EqualError(t, err, tc.Error) + assert.EqualError(t, err, tc.Error, "Compiler.Compile(%v)", tc.ReferenceChecker) } }) } type fieldValidatorTestCase struct { - Name string - Validator schema.FieldValidator - Input, Expect interface{} - Error string + Name string + Validator schema.FieldValidator + ReferenceChecker schema.ReferenceChecker + Input, Expect interface{} + Error string } func (tc fieldValidatorTestCase) Run(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { t.Parallel() + if cmp, ok := tc.Validator.(schema.Compiler); ok { + err := cmp.Compile(tc.ReferenceChecker) + assert.NoError(t, err, "Validator.Compile(%v)", tc.ReferenceChecker) + } + v, err := tc.Validator.Validate(tc.Input) if tc.Error == "" { - assert.NoError(t, err) - assert.Equal(t, tc.Expect, v) + assert.NoError(t, err, "Validator.Validate(%v)", tc.Input) + assert.Equal(t, tc.Expect, v, "Validator.Validate(%v)", tc.Input) + } else { + assert.EqualError(t, err, tc.Error, "Validator.Validate(%v)", tc.Input) + assert.Nil(t, v, "Validator.Validate(%v)", tc.Input) + } + }) +} + +type fakeReferenceChecker map[string]struct { + IDs []interface{} + Validator schema.FieldValidator +} + +func (rc fakeReferenceChecker) Compile() error { + for name := range rc { + if rc[name].Validator == nil { + continue + } + if cmp, ok := rc[name].Validator.(schema.Compiler); ok { + if err := cmp.Compile(rc); err != nil { + return err + } + } + } + return nil +} + +func (rc fakeReferenceChecker) ReferenceChecker(path string) schema.FieldValidator { + rsc, ok := rc[path] + if !ok { + return nil + } + return schema.FieldValidatorFunc(func(value interface{}) (interface{}, error) { + var id interface{} + var err error + + // Sanitize ID from input value. + if rsc.Validator != nil { + id, err = rsc.Validator.Validate(value) + if err != nil { + return nil, err + } } else { - assert.EqualError(t, err, tc.Error) - assert.Nil(t, v) + id = value + } + // Check that the ID exists. + for _, rscID := range rsc.IDs { + if id == rscID { + return id, nil + } } + return nil, errors.New("not found") }) } diff --git a/schema/alloff.go b/schema/alloff.go index bcb6cc81..da6559cc 100644 --- a/schema/alloff.go +++ b/schema/alloff.go @@ -3,11 +3,11 @@ package schema // AllOf validates that all the sub field validators validates. type AllOf []FieldValidator -// Compile implements Compiler interface. -func (v *AllOf) Compile() (err error) { - for _, sv := range *v { +// Compile implements the ReferenceCompiler interface. +func (v AllOf) Compile(rc ReferenceChecker) (err error) { + for _, sv := range v { if c, ok := sv.(Compiler); ok { - if err = c.Compile(); err != nil { + if err = c.Compile(rc); err != nil { return } } diff --git a/schema/alloff_test.go b/schema/alloff_test.go index 42513e45..2d06e1be 100644 --- a/schema/alloff_test.go +++ b/schema/alloff_test.go @@ -1,29 +1,69 @@ -package schema +package schema_test import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/rs/rest-layer/schema" ) func TestAllOfValidatorCompile(t *testing.T) { - v := &AllOf{&String{}} - err := v.Compile() - assert.NoError(t, err) - v = &AllOf{&String{Regexp: "[invalid re"}} - err = v.Compile() - assert.EqualError(t, err, "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`") - + cases := []referenceCompilerTestCase{ + { + Name: "{String}", + Compiler: &schema.AllOf{&schema.String{}}, + }, + { + Name: "{String{Regexp:invalid}}", + Compiler: &schema.AllOf{&schema.String{Regexp: "[invalid re"}}, + Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", + }, + } + for i := range cases { + cases[i].Run(t) + } } func TestAllOfValidator(t *testing.T) { - v, err := AllOf{&Bool{}, &Bool{}}.Validate(true) - assert.NoError(t, err) - assert.Equal(t, true, v) - v, err = AllOf{&Bool{}, &Bool{}}.Validate("") - assert.EqualError(t, err, "not a Boolean") - assert.Equal(t, nil, v) - v, err = AllOf{&Bool{}, &String{}}.Validate(true) - assert.EqualError(t, err, "not a string") - assert.Equal(t, nil, v) + cases := []fieldValidatorTestCase{ + { + Name: "{String}.Validate(true)", + Validator: schema.AllOf{&schema.Bool{}, &schema.Bool{}}, + Input: true, + Expect: true, + }, + { + Name: `{Bool, String}.Validate("")`, + Validator: schema.AllOf{&schema.Bool{}, &schema.String{}}, + Input: "", + Error: "not a Boolean", + }, + { + Name: "{Bool, String}.Validate(true)", + Validator: schema.AllOf{&schema.Bool{}, &schema.String{}}, + Input: true, + Error: "not a string", + }, + { + Name: `{Reference{Path:"foo"},Reference{Path:"bar"}}.Validate(validFooReference)`, + Validator: schema.AllOf{ + &schema.Reference{Path: "foo"}, + &schema.Reference{Path: "bar"}, + }, + ReferenceChecker: fakeReferenceChecker{ + "foo": { + IDs: []interface{}{"foo1"}, + Validator: &schema.String{}, + }, + "bar": { + IDs: []interface{}{"bar1", "bar2", "bar3"}, + Validator: &schema.String{}, + }, + }, + Input: "foo1", + Error: "not found", + }, + } + for i := range cases { + cases[i].Run(t) + } } diff --git a/schema/anyof.go b/schema/anyof.go index 7f4ea383..d8039286 100644 --- a/schema/anyof.go +++ b/schema/anyof.go @@ -5,23 +5,23 @@ import "errors" // AnyOf validates if any of the sub field validators validates. type AnyOf []FieldValidator -// Compile implements Compiler interface. -func (v *AnyOf) Compile() (err error) { - for _, sv := range *v { +// Compile implements the Compiler interface. +func (v AnyOf) Compile(rc ReferenceChecker) error { + for _, sv := range v { if c, ok := sv.(Compiler); ok { - if err = c.Compile(); err != nil { - return + if err := c.Compile(rc); err != nil { + return err } } + } - return + return nil } // Validate ensures that at least one sub-validator validates. func (v AnyOf) Validate(value interface{}) (interface{}, error) { for _, validator := range v { - var err error - if value, err = validator.Validate(value); err == nil { + if value, err := validator.Validate(value); err == nil { return value, nil } } diff --git a/schema/anyof_test.go b/schema/anyof_test.go index 54ca245c..6b76757e 100644 --- a/schema/anyof_test.go +++ b/schema/anyof_test.go @@ -1,29 +1,88 @@ -package schema +package schema_test import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/rs/rest-layer/schema" ) -func TestAnyOfValidatorCompile(t *testing.T) { - v := &AnyOf{&String{}} - err := v.Compile() - assert.NoError(t, err) - v = &AnyOf{&String{Regexp: "[invalid re"}} - err = v.Compile() - assert.EqualError(t, err, "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`") - +func TestAnyOfCompile(t *testing.T) { + cases := []referenceCompilerTestCase{ + { + Name: "{String}", + Compiler: &schema.AnyOf{&schema.String{}}, + ReferenceChecker: fakeReferenceChecker{}, + }, + { + Name: "{String{Regexp:invalid}}", + Compiler: &schema.AnyOf{&schema.String{Regexp: "[invalid re"}}, + ReferenceChecker: fakeReferenceChecker{}, + Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", + }, + { + Name: "{Reference{Path:valid}}", + Compiler: &schema.AnyOf{&schema.Reference{Path: "items"}}, + ReferenceChecker: fakeReferenceChecker{"items": {IDs: []interface{}{1, 2, 3}, Validator: &schema.Integer{}}}, + }, + { + Name: "{Reference{Path:invalid}}", + Compiler: &schema.AnyOf{&schema.Reference{Path: "foobar"}}, + ReferenceChecker: fakeReferenceChecker{"items": {IDs: []interface{}{1, 2, 3}, Validator: &schema.Integer{}}}, + Error: "can't find resource 'foobar'", + }, + } + for i := range cases { + cases[i].Run(t) + } } -func TestAnyOfValidator(t *testing.T) { - v, err := AnyOf{&Bool{}, &Bool{}}.Validate(true) - assert.NoError(t, err) - assert.Equal(t, true, v) - v, err = AnyOf{&Bool{}, &Bool{}}.Validate("") - assert.EqualError(t, err, "invalid") - assert.Equal(t, nil, v) - v, err = AnyOf{&Bool{}, &String{}}.Validate(true) - assert.NoError(t, err) - assert.Equal(t, true, v) +func TestAnyOfValidate(t *testing.T) { + cases := []fieldValidatorTestCase{ + { + Name: "{Bool,Bool}.Validate(true)", + Validator: schema.AnyOf{&schema.Bool{}, &schema.Bool{}}, + Input: true, + Expect: true, + }, + { + Name: `{Bool,Bool}.Validate("")`, + Validator: schema.AnyOf{&schema.Bool{}, &schema.Bool{}}, + Input: "", + Error: "invalid", + }, + { + Name: "{Bool,String}.Validate(true)", + Validator: schema.AnyOf{&schema.Bool{}, &schema.String{}}, + Input: true, + Expect: true, + }, + { + Name: `{Bool,String}.Validate("")`, + Validator: schema.AnyOf{&schema.Bool{}, &schema.String{}}, + Input: "", + Expect: "", + }, + { + Name: `{Reference{Path:"foo"},Reference{Path:"bar"}}.Validate(validFooReference)`, + Validator: schema.AnyOf{ + &schema.Reference{Path: "foo"}, + &schema.Reference{Path: "bar"}, + }, + ReferenceChecker: fakeReferenceChecker{ + "foo": { + IDs: []interface{}{"foo1"}, + Validator: &schema.String{}, + }, + "bar": { + IDs: []interface{}{"bar1", "bar2", "bar3"}, + Validator: &schema.String{}, + }, + }, + Input: "foo1", + Expect: "foo1", + }, + } + for i := range cases { + cases[i].Run(t) + } } diff --git a/schema/array.go b/schema/array.go index ca1b85b1..245f44fd 100644 --- a/schema/array.go +++ b/schema/array.go @@ -15,10 +15,10 @@ type Array struct { MaxLen int } -// Compile implements Compiler interface. -func (v *Array) Compile() (err error) { +// Compile implements the ReferenceCompiler interface. +func (v *Array) Compile(rc ReferenceChecker) (err error) { if c, ok := v.ValuesValidator.(Compiler); ok { - if err = c.Compile(); err != nil { + if err = c.Compile(rc); err != nil { return } } diff --git a/schema/array_test.go b/schema/array_test.go index 83ba5bb3..a66cb068 100644 --- a/schema/array_test.go +++ b/schema/array_test.go @@ -9,15 +9,17 @@ import ( ) func TestArrayValidatorCompile(t *testing.T) { - testCases := []compilerTestCase{ + testCases := []referenceCompilerTestCase{ { - Name: "ValuesValidator=&String{}", - Compiler: &schema.Array{ValuesValidator: &schema.String{}}, + Name: "ValuesValidator=&String{}", + Compiler: &schema.Array{ValuesValidator: &schema.String{}}, + ReferenceChecker: fakeReferenceChecker{}, }, { - Name: "ValuesValidator=&String{Regexp:invalid}", - Compiler: &schema.Array{ValuesValidator: &schema.String{Regexp: "[invalid re"}}, - Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", + Name: "ValuesValidator=&String{Regexp:invalid}", + Compiler: &schema.Array{ValuesValidator: &schema.String{Regexp: "[invalid re"}}, + ReferenceChecker: fakeReferenceChecker{}, + Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", }, } for i := range testCases { diff --git a/schema/compiler.go b/schema/compiler.go new file mode 100644 index 00000000..aa3f2415 --- /dev/null +++ b/schema/compiler.go @@ -0,0 +1,22 @@ +package schema + +// Compiler is similar to the Compiler interface, but intended for types that implements, or may hold, a +// reference. All nested types must implement this interface. +type Compiler interface { + Compile(rc ReferenceChecker) error +} + +// ReferenceChecker is used to retrieve a FieldValidator that can be used for validating referenced IDs. +type ReferenceChecker interface { + // ReferenceChecker should return a FieldValidator that can be used for validate that a referenced ID exists and + // is of the right format. If there is no resource matching path, nil should e returned. + ReferenceChecker(path string) FieldValidator +} + +// ReferenceCheckerFunc is an adapter that allows ordinary functions to be used as reference checkers. +type ReferenceCheckerFunc func(path string) FieldValidator + +// ReferenceChecker calls f(path). +func (f ReferenceCheckerFunc) ReferenceChecker(path string) FieldValidator { + return f(path) +} diff --git a/schema/dict.go b/schema/dict.go index b2c29f1e..d30cf1b6 100644 --- a/schema/dict.go +++ b/schema/dict.go @@ -17,15 +17,15 @@ type Dict struct { MaxLen int } -// Compile implements Compiler interface. -func (v *Dict) Compile() (err error) { +// Compile implements the ReferenceCompiler interface. +func (v *Dict) Compile(rc ReferenceChecker) (err error) { if c, ok := v.KeysValidator.(Compiler); ok { - if err = c.Compile(); err != nil { + if err = c.Compile(rc); err != nil { return } } if c, ok := v.ValuesValidator.(Compiler); ok { - if err = c.Compile(); err != nil { + if err = c.Compile(rc); err != nil { return } } diff --git a/schema/dict_test.go b/schema/dict_test.go index d77d438d..83bb1d9e 100644 --- a/schema/dict_test.go +++ b/schema/dict_test.go @@ -8,24 +8,38 @@ import ( "github.com/rs/rest-layer/schema" ) -func TestDictValidatorCompile(t *testing.T) { - testCases := []compilerTestCase{ +func TestDictCompile(t *testing.T) { + testCases := []referenceCompilerTestCase{ { - Name: "KeysValidator=&String{},ValuesValidator=&String{}", + Name: "{KeysValidator:String,ValuesValidator:String}", Compiler: &schema.Dict{ KeysValidator: &schema.String{}, ValuesValidator: &schema.String{}, }, + ReferenceChecker: fakeReferenceChecker{}, }, { - Name: "KeysValidator=&String{Regexp:invalid}", - Compiler: &schema.Dict{KeysValidator: &schema.String{Regexp: "[invalid re"}}, - Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", + Name: "{KeysValidator:String{Regexp:invalid}}", + Compiler: &schema.Dict{KeysValidator: &schema.String{Regexp: "[invalid re"}}, + ReferenceChecker: fakeReferenceChecker{}, + Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", }, { - Name: "ValuesValidator=&String{Regexp:invalid}", - Compiler: &schema.Dict{ValuesValidator: &schema.String{Regexp: "[invalid re"}}, - Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", + Name: "{ValuesValidator:String{Regexp:invalid}}", + Compiler: &schema.Dict{ValuesValidator: &schema.String{Regexp: "[invalid re"}}, + ReferenceChecker: fakeReferenceChecker{}, + Error: "invalid regexp: error parsing regexp: missing closing ]: `[invalid re`", + }, + { + Name: "{ValuesValidator:Reference{Path:valid}}", + Compiler: &schema.Dict{ValuesValidator: &schema.Reference{Path: "foo"}}, + ReferenceChecker: fakeReferenceChecker{"foo": {}}, + }, + { + Name: "{ValuesValidator:Reference{Path:invalid}}", + Compiler: &schema.Dict{ValuesValidator: &schema.Reference{Path: "bar"}}, + ReferenceChecker: fakeReferenceChecker{"foo": {}}, + Error: "can't find resource 'bar'", }, } for i := range testCases { @@ -33,64 +47,64 @@ func TestDictValidatorCompile(t *testing.T) { } } -func TestDictValidator(t *testing.T) { +func TestDictValidate(t *testing.T) { testCases := []fieldValidatorTestCase{ { - Name: `KeysValidator=&String{},Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{KeysValidator:String}.Validate(valid)`, Validator: &schema.Dict{KeysValidator: &schema.String{}}, Input: map[string]interface{}{"foo": true, "bar": false}, Expect: map[string]interface{}{"foo": true, "bar": false}, }, { - Name: `KeysValidator=&String{MinLen:3},Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{KeysValidator:String{MinLen:3}}.Validate(valid)`, Validator: &schema.Dict{KeysValidator: &schema.String{MinLen: 3}}, Input: map[string]interface{}{"foo": true, "bar": false}, Expect: map[string]interface{}{"foo": true, "bar": false}, }, { - Name: `KeysValidator=&String{MinLen:3},Validate(map[string]interface{}{"foo":true,"ba":false})`, + Name: `{KeysValidator:String{MinLen:3}}.Validate(invalid)`, Validator: &schema.Dict{KeysValidator: &schema.String{MinLen: 3}}, Input: map[string]interface{}{"foo": true, "ba": false}, Error: "invalid key `ba': is shorter than 3", }, { - Name: `ValuesValidator=&Bool{},Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{ValuesValidator:Bool}.Validate(valid)`, Validator: &schema.Dict{ValuesValidator: &schema.Bool{}}, Input: map[string]interface{}{"foo": true, "bar": false}, Expect: map[string]interface{}{"foo": true, "bar": false}, }, { - Name: `ValuesValidator=&Bool{},Validate(map[string]interface{}{"foo":true,"bar":"value"})`, + Name: `{ValuesValidator:Bool}.Validate({"foo":true,"bar":"value"})`, Validator: &schema.Dict{ValuesValidator: &schema.Bool{}}, Input: map[string]interface{}{"foo": true, "bar": "value"}, Error: "invalid value for key `bar': not a Boolean", }, { - Name: `ValuesValidator=&String{},Validate("value")`, + Name: `{ValuesValidator:String}.Validate("")`, Validator: &schema.Dict{ValuesValidator: &schema.String{}}, - Input: "value", + Input: "", Error: "not a dict", }, { - Name: `MinLen=2,Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{MinLen:2}.Validate({"foo":true,"bar":false})`, Validator: &schema.Dict{MinLen: 2}, Input: map[string]interface{}{"foo": true, "bar": "value"}, Expect: map[string]interface{}{"foo": true, "bar": "value"}, }, { - Name: `MinLen=3,Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{MinLen=3}.Validate({"foo":true,"bar":false})`, Validator: &schema.Dict{MinLen: 3}, Input: map[string]interface{}{"foo": true, "bar": "value"}, Error: "has fewer properties than 3", }, { - Name: `MaxLen=2,Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{MaxLen=2}.Validate({"foo":true,"bar":false})`, Validator: &schema.Dict{MaxLen: 3}, Input: map[string]interface{}{"foo": true, "bar": "value"}, Expect: map[string]interface{}{"foo": true, "bar": "value"}, }, { - Name: `MaxLen=1,Validate(map[string]interface{}{"foo":true,"bar":false})`, + Name: `{MaxLen=1}.Validate({"foo":true,"bar":false})`, Validator: &schema.Dict{MaxLen: 1}, Input: map[string]interface{}{"foo": true, "bar": "value"}, Error: "has more properties than 1", diff --git a/schema/encoding/jsonschema/benchmark_test.go b/schema/encoding/jsonschema/benchmark_test.go index cd086ea4..cfa28e45 100644 --- a/schema/encoding/jsonschema/benchmark_test.go +++ b/schema/encoding/jsonschema/benchmark_test.go @@ -106,7 +106,7 @@ func complexSchema1() schema.Schema { }, }, } - Must(s.Compile()) + Must(s.Compile(nil)) return s } @@ -204,6 +204,6 @@ func complexSchema2() schema.Schema { }, }, } - Must(s.Compile()) + Must(s.Compile(nil)) return s } diff --git a/schema/encoding/jsonschema/testutil_test.go b/schema/encoding/jsonschema/testutil_test.go index 5f6b874e..db1623d9 100644 --- a/schema/encoding/jsonschema/testutil_test.go +++ b/schema/encoding/jsonschema/testutil_test.go @@ -133,9 +133,9 @@ var ( ) func init() { - Must(simpleSchema.Compile()) - Must(nestedObjectsSchema.Compile()) - Must(arrayOfObjectsSchema.Compile()) + Must(simpleSchema.Compile(nil)) + Must(nestedObjectsSchema.Compile(nil)) + Must(arrayOfObjectsSchema.Compile(nil)) } // JSON serialization of reusable schemas. diff --git a/schema/field.go b/schema/field.go index 84fe9ab7..6ef232f2 100644 --- a/schema/field.go +++ b/schema/field.go @@ -73,6 +73,16 @@ type FieldValidator interface { Validate(value interface{}) (interface{}, error) } +//FieldValidatorFunc is an adapter to allow the use of ordinary functions as field validators. +// If f is a function with the appropriate signature, FieldValidatorFunc(f) is a FieldValidator +// that calls f. +type FieldValidatorFunc func(value interface{}) (interface{}, error) + +// Validate calls f(value). +func (f FieldValidatorFunc) Validate(value interface{}) (interface{}, error) { + return f(value) +} + // FieldSerializer is used to convert the value between it's representation form // and it internal storable form. A FieldValidator which implement this // interface will have its Serialize method called before marshaling. @@ -83,19 +93,19 @@ type FieldSerializer interface { Serialize(value interface{}) (interface{}, error) } -// Compile implements Compiler interface and recursively compile sub schemas and -// validators when they implement Compiler interface. -func (f Field) Compile() error { +// Compile implements the ReferenceCompiler interface and recursively compile sub schemas +// and validators when they implement Compiler interface. +func (f Field) Compile(rc ReferenceChecker) error { // TODO check field name format (alpha num + _ and -). if f.Schema != nil { // Recursively compile sub schema if any. - if err := f.Schema.Compile(); err != nil { + if err := f.Schema.Compile(rc); err != nil { return fmt.Errorf(".%v", err) } } else if f.Validator != nil { - // Compile validator if it implements Compiler interface. + // Compile validator if it implements the ReferenceCompiler or Compiler interface. if c, ok := f.Validator.(Compiler); ok { - if err := c.Compile(); err != nil { + if err := c.Compile(rc); err != nil { return fmt.Errorf(": %v", err) } } diff --git a/schema/object.go b/schema/object.go index 3cb66f8d..1d7b728d 100644 --- a/schema/object.go +++ b/schema/object.go @@ -12,18 +12,33 @@ type Object struct { Schema *Schema } -// Compile implements Compiler interface. -func (v *Object) Compile() error { +// Compile implements the ReferenceCompiler interface. +func (v *Object) Compile(rc ReferenceChecker) error { if v.Schema == nil { - return fmt.Errorf("No schema defined for object") + return errors.New("no schema defined") } if err := compileDependencies(*v.Schema, v.Schema); err != nil { return err } - return v.Schema.Compile() + return v.Schema.Compile(rc) } -// ErrorMap to return lots of errors. +// Validate implements FieldValidator interface. +func (v Object) Validate(value interface{}) (interface{}, error) { + obj, ok := value.(map[string]interface{}) + if !ok { + return nil, errors.New("not an object") + } + dest, errs := v.Schema.Validate(nil, obj) + if len(errs) > 0 { + var errMap ErrorMap + errMap = errs + return nil, errMap + } + return dest, nil +} + +// ErrorMap contains a map of errors by field name. type ErrorMap map[string][]interface{} func (e ErrorMap) Error() string { @@ -37,17 +52,3 @@ func (e ErrorMap) Error() string { } return strings.Join(errs, ", ") } - -// Validate implements FieldValidator interface. -func (v Object) Validate(value interface{}) (interface{}, error) { - dict, ok := value.(map[string]interface{}) - if !ok { - return nil, errors.New("not a dict") - } - dest, errs := v.Schema.Validate(nil, dict) - if len(errs) > 0 { - var errMap ErrorMap = errs - return nil, errMap - } - return dest, nil -} diff --git a/schema/object_test.go b/schema/object_test.go index ea58d8d4..66515a96 100644 --- a/schema/object_test.go +++ b/schema/object_test.go @@ -1,232 +1,113 @@ -package schema +package schema_test import ( - "errors" "testing" + "github.com/rs/rest-layer/schema" "github.com/stretchr/testify/assert" ) -type uncompilableValidator struct{} - -func (v uncompilableValidator) Compile() error { - return errors.New("compilation failed") -} - -func (v uncompilableValidator) Validate(value interface{}) (interface{}, error) { - return value, nil -} - -func TestInvalidObjectValidatorCompile(t *testing.T) { - v := &Object{} - err := v.Compile() - assert.Error(t, err) -} - -func TestObjectValidatorCompile(t *testing.T) { - v := &Object{ - Schema: &Schema{}, - } - err := v.Compile() - assert.NoError(t, err) -} - -func TestObjectWithSchemaValidatorCompile(t *testing.T) { - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, +func TestObjectCompile(t *testing.T) { + cases := []referenceCompilerTestCase{ + { + Name: "{}", + Compiler: &schema.Object{}, + ReferenceChecker: fakeReferenceChecker{}, + Error: "no schema defined", }, - } - err := v.Compile() - assert.NoError(t, err) -} - -func TestObjectWithSchemaValidatorCompileError(t *testing.T) { - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "foo": Field{ - Validator: &uncompilableValidator{}, - }, - }, + { + Name: "{Schema:{}}", + Compiler: &schema.Object{Schema: &schema.Schema{}}, + ReferenceChecker: fakeReferenceChecker{}, }, - } - err := v.Compile() - assert.EqualError(t, err, "foo: compilation failed") -} - -func TestObjectValidator(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = "hello" - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, + { + Name: `{Schema:{"foo":String}}`, + Compiler: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.String{}}, + }}}, + ReferenceChecker: fakeReferenceChecker{}, }, - } - assert.NoError(t, v.Compile()) - doc, err := v.Validate(obj) - assert.NoError(t, err) - assert.Equal(t, obj, doc) -} - -func TestInvalidObjectValidator(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = 1 - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, + { + Name: `{Schema:{"foo":Reference{Path:valid}}}`, + Compiler: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.Reference{Path: "bar"}}, + }}}, + ReferenceChecker: fakeReferenceChecker{"bar": {}}, }, - } - assert.NoError(t, v.Compile()) - _, err := v.Validate(obj) - assert.Error(t, err) -} - -func TestErrorObjectCast(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = 1 - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, + { + Name: `{Schema:{"foo":Reference{Path:invalid}}}`, + Compiler: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.Reference{Path: "foobar"}}, + }}}, + ReferenceChecker: fakeReferenceChecker{"bar": {}}, + Error: "foo: can't find resource 'foobar'", }, } - assert.NoError(t, v.Compile()) - _, err := v.Validate(obj) - switch errMap := err.(type) { - case ErrorMap: - assert.True(t, true) - assert.Len(t, errMap, 1) - default: - assert.True(t, false) + for i := range cases { + cases[i].Run(t) } } -func TestArrayOfObject(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = "a" - objb := make(map[string]interface{}) - objb["test"] = "b" - value := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, +func TestObjectValidate(t *testing.T) { + cases := []fieldValidatorTestCase{ + { + Name: `{Schema:{"foo":String}}.Validate(valid)`, + Validator: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.String{}}, + }}}, + Input: map[string]interface{}{"foo": "hello"}, + Expect: map[string]interface{}{"foo": "hello"}, }, - } - array := Array{ValuesValidator: value} - a := []interface{}{obj, objb} - assert.NoError(t, array.Compile()) - _, err := array.Validate(a) - assert.NoError(t, err) -} - -func TestErrorArrayOfObject(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = "a" - objb := make(map[string]interface{}) - objb["test"] = 1 - value := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, + { + Name: `{Schema:{"foo":String}}.Validate(invalid)`, + Validator: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.String{}}, + }}}, + Input: map[string]interface{}{"foo": 1}, + Error: "foo is [not a string]", }, - } - array := Array{ValuesValidator: value} - a := []interface{}{obj, objb} - assert.NoError(t, array.Compile()) - _, err := array.Validate(a) - assert.Error(t, err) -} - -func TestErrorBasicMessage(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = 1 - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - }, + { + Name: `{Schema:{"test":String,"count:Integer"}}.Validate(doubleError)`, + Validator: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "test": {Validator: &schema.String{}}, + "count": {Validator: &schema.Integer{}}, + }}}, + Input: map[string]interface{}{"test": 1, "count": "hello"}, + Error: "count is [not an integer], test is [not a string]", }, - } - assert.NoError(t, v.Compile()) - _, err := v.Validate(obj) - errMap, ok := err.(ErrorMap) - assert.True(t, ok) - assert.Len(t, errMap, 1) - assert.Equal(t, "test is [not a string]", errMap.Error()) -} - -func Test2ErrorFieldMessages(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = 1 - obj["count"] = "blah" - v := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - "count": Field{ - Validator: &Integer{}, - }, + { + Name: `{Schema:{"foo":Reference{Path:valid}}}.Validate(valid)`, + Validator: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.Reference{Path: "bar"}}, + }}}, + ReferenceChecker: fakeReferenceChecker{ + "bar": {IDs: []interface{}{"a", "b"}, Validator: &schema.String{}}, }, + Input: map[string]interface{}{"foo": "a"}, + Expect: map[string]interface{}{"foo": "a"}, }, - } - assert.NoError(t, v.Compile()) - _, err := v.Validate(obj) - errMap, ok := err.(ErrorMap) - assert.True(t, ok) - assert.Len(t, errMap, 2) - assert.Equal(t, "count is [not an integer], test is [not a string]", errMap.Error()) -} - -func TestErrorMessagesForObjectValidatorEmbeddedInArray(t *testing.T) { - obj := make(map[string]interface{}) - obj["test"] = 1 - obj["isUp"] = "false" - value := &Object{ - Schema: &Schema{ - Fields: Fields{ - "test": Field{ - Validator: &String{}, - }, - "isUp": Field{ - Validator: &Bool{}, - }, + { + Name: `{Schema:{"foo":Reference{Path:valid}}}.Validate(invalid)`, + Validator: &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.Reference{Path: "bar"}}, + }}}, + ReferenceChecker: fakeReferenceChecker{ + "bar": {IDs: []interface{}{"a", "b"}, Validator: &schema.String{}}, }, + Input: map[string]interface{}{"foo": "c"}, + Error: "foo is [not found]", }, } - assert.NoError(t, value.Compile()) - - array := Array{ValuesValidator: value} + for i := range cases { + cases[i].Run(t) + } +} - // Not testing multiple array values being errors because Array - // implementation stops validating on first error found in array. - a := []interface{}{obj} - _, err := array.Validate(a) - assert.Error(t, err) - assert.Equal(t, "invalid value at #1: isUp is [not a Boolean], test is [not a string]", err.Error()) +func TestObjectValidatorErrorType(t *testing.T) { + obj := map[string]interface{}{"foo": 1} + v := &schema.Object{Schema: &schema.Schema{Fields: schema.Fields{ + "foo": {Validator: &schema.String{}}, + }}} + _, err := v.Validate(obj) + assert.IsType(t, schema.ErrorMap{}, err, "Unexpected error type") } diff --git a/schema/reference.go b/schema/reference.go index fcc77c7e..815a888e 100644 --- a/schema/reference.go +++ b/schema/reference.go @@ -1,12 +1,35 @@ package schema -// Reference validates time based values. +import ( + "errors" + "fmt" +) + +// Reference validates the ID of a linked resource. type Reference struct { - Path string + Path string + validator FieldValidator +} + +// Compile validates v.Path against rc and stores the a FieldValidator for later use by v.Validate. +func (r *Reference) Compile(rc ReferenceChecker) error { + if rc == nil { + return fmt.Errorf("rc can not be nil") + } + + if v := rc.ReferenceChecker(r.Path); v != nil { + r.validator = v + return nil + } + + return fmt.Errorf("can't find resource '%s'", r.Path) } -// Validate validates and normalize reference based value. -func (v Reference) Validate(value interface{}) (interface{}, error) { - // All the work is performed in rest.checkReferences(). - return value, nil +// Validate validates and sanitizes IDs against the reference path. +func (r Reference) Validate(value interface{}) (interface{}, error) { + if r.validator == nil { + return nil, errors.New("not successfully compiled") + } + + return r.validator.Validate(value) } diff --git a/schema/reference_test.go b/schema/reference_test.go index 181c547c..734baf16 100644 --- a/schema/reference_test.go +++ b/schema/reference_test.go @@ -1,13 +1,33 @@ -package schema +package schema_test import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/rs/rest-layer/schema" ) func TestReferenceValidate(t *testing.T) { - v, err := Reference{}.Validate("test") - assert.NoError(t, err) - assert.Equal(t, "test", v) + cases := []fieldValidatorTestCase{ + { + Name: `{Path:valid}.Validate(valid)`, + Validator: &schema.Reference{Path: "foobar"}, + ReferenceChecker: fakeReferenceChecker{ + "foobar": {IDs: []interface{}{"a", "b"}, Validator: &schema.String{}}, + }, + Input: "a", + Expect: "a", + }, + { + Name: `{Path:valid}.Validate(invalid)`, + Validator: &schema.Reference{Path: "foobar"}, + ReferenceChecker: fakeReferenceChecker{ + "foobar": {IDs: []interface{}{"a", "b"}, Validator: &schema.String{}}, + }, + Input: "c", + Error: "not found", + }, + } + for i := range cases { + cases[i].Run(t) + } } diff --git a/schema/schema.go b/schema/schema.go index bc1e16b2..7d4df083 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -8,6 +8,18 @@ import ( "strings" ) +type internal struct{} + +// Tombstone is used to mark a field for removal +var Tombstone = internal{} + +// Validator is an interface used to validate schema against actual data +type Validator interface { + GetField(name string) *Field + Prepare(ctx context.Context, payload map[string]interface{}, original *map[string]interface{}, replace bool) (changes map[string]interface{}, base map[string]interface{}) + Validate(changes map[string]interface{}, base map[string]interface{}) (doc map[string]interface{}, errs map[string][]interface{}) +} + // Schema defines fields for a document type Schema struct { // Description of the object described by this schema. @@ -20,55 +32,18 @@ type Schema struct { MaxLen int } -// Validator is an interface used to validate schema against actual data. -type Validator interface { - GetField(name string) *Field - Prepare(ctx context.Context, payload map[string]interface{}, original *map[string]interface{}, replace bool) (changes map[string]interface{}, base map[string]interface{}) - Validate(changes map[string]interface{}, base map[string]interface{}) (doc map[string]interface{}, errs map[string][]interface{}) -} - -// Compiler is an interface defining a validator that can be compiled at run -// time in order to check validator configuration validity and/or prepare some -// data for a faster execution. -type Compiler interface { - Compile() error -} - -type internal struct{} - -// Tombstone is used to mark a field for removal -var Tombstone = internal{} - -func addFieldError(errs map[string][]interface{}, field string, err interface{}) { - errs[field] = append(errs[field], err) -} - -func mergeFieldErrors(errs map[string][]interface{}, mergeErrs map[string][]interface{}) { - // TODO recursive merge. - for field, values := range mergeErrs { - if dest, found := errs[field]; found { - for _, value := range values { - dest = append(dest, value) - } - } else { - errs[field] = values - } - } -} - -// Compile implements Compiler interface and call the same function on each -// field. Note: if you use schema as a standalone library, it is the *caller's* -// responsibility to invoke the Compile method before using Prepare or Validate -// on a Schema instance, otherwise FieldValidator instances may not be -// initialized correctly. -func (s Schema) Compile() error { - // Search for all dependencies on fields, and compile then. +// Compile implements the ReferenceCompiler interface and call the same function +// on each field. Note: if you use schema as a standalone library, it is the +// *caller's* responsibility to invoke the Compile method before using Prepare +// or Validate on a Schema instance, otherwise FieldValidator instances may not +// be initialized correctly. +func (s Schema) Compile(rc ReferenceChecker) error { if err := compileDependencies(s, s); err != nil { return err } for field, def := range s.Fields { - // Compile each field - if err := def.Compile(); err != nil { + // Compile each field. + if err := def.Compile(rc); err != nil { return fmt.Errorf("%s%v", field, err) } } @@ -104,22 +79,25 @@ func (s Schema) GetField(name string) *Field { return nil } -// Prepare takes a payload with an optional original payout when updating an existing item and -// return two maps, one containing changes operated by the user and another defining either -// existing data (from the current item) or data generated by the system thru "default" value -// or hooks. +// Prepare takes a payload with an optional original payout when updating an +// existing item and return two maps, one containing changes operated by the +// user and another defining either existing data (from the current item) or +// data generated by the system thru "default" value or hooks. // -// If the original map is nil, prepare will act as if the payload is a new document. The OnInit -// hook is executed for each field if any, and default values are assigned to missing fields. +// If the original map is nil, prepare will act as if the payload is a new +// document. The OnInit hook is executed for each field if any, and default +// values are assigned to missing fields. // -// When the original map is defined, the payload is considered as an update on the original document, -// default values are not assigned, and only fields which are different than in the original are -// left in the change map. The OnUpdate hook is executed on each field. +// When the original map is defined, the payload is considered as an update on +// the original document, default values are not assigned, and only fields which +// are different than in the original are left in the change map. The OnUpdate +// hook is executed on each field. // -// If the replace argument is set to true with the original document set, the behavior is slightly -// different as any field not present in the payload but present in the original are set to nil -// in the change map (instead of just being absent). This instruct the validator that the field -// has been edited, so ReadOnly flag can throw an error and the field will be removed from the +// If the replace argument is set to true with the original document set, the +// behavior is slightly different as any field not present in the payload but +// present in the original are set to nil in the change map (instead of just +// being absent). This instruct the validator that the field has been edited, so +// ReadOnly flag can throw an error and the field will be removed from the // output document. The OnInit is also called instead of the OnUpdate. func (s Schema) Prepare(ctx context.Context, payload map[string]interface{}, original *map[string]interface{}, replace bool) (changes map[string]interface{}, base map[string]interface{}) { changes = map[string]interface{}{} @@ -236,6 +214,7 @@ func (s Schema) Prepare(ctx context.Context, payload map[string]interface{}, ori func (s Schema) Validate(changes map[string]interface{}, base map[string]interface{}) (doc map[string]interface{}, errs map[string][]interface{}) { return s.validate(changes, base, true) } + func (s Schema) validate(changes map[string]interface{}, base map[string]interface{}, isRoot bool) (doc map[string]interface{}, errs map[string][]interface{}) { doc = map[string]interface{}{} errs = map[string][]interface{}{} @@ -345,3 +324,20 @@ func (s Schema) validate(changes map[string]interface{}, base map[string]interfa } return doc, errs } + +func addFieldError(errs map[string][]interface{}, field string, err interface{}) { + errs[field] = append(errs[field], err) +} + +func mergeFieldErrors(errs map[string][]interface{}, mergeErrs map[string][]interface{}) { + // TODO recursive merge + for field, values := range mergeErrs { + if dest, found := errs[field]; found { + for _, value := range values { + dest = append(dest, value) + } + } else { + errs[field] = values + } + } +} diff --git a/schema/schema_test.go b/schema/schema_test.go index e35d3e8c..1072a883 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -8,6 +8,11 @@ import ( ) func TestSchemaValidator(t *testing.T) { + rc := fakeReferenceChecker{ + "foobar": {IDs: []interface{}{1, 2, 3}, Validator: &schema.Integer{}}, + } + assert.NoError(t, rc.Compile(), "rc compile error") + minLenSchema := &schema.Schema{ Fields: schema.Fields{ "foo": schema.Field{ @@ -22,7 +27,7 @@ func TestSchemaValidator(t *testing.T) { }, MinLen: 2, } - assert.NoError(t, minLenSchema.Compile()) + assert.NoError(t, minLenSchema.Compile(rc), "minLenSchema compile error") maxLenSchema := &schema.Schema{ Fields: schema.Fields{ @@ -38,9 +43,9 @@ func TestSchemaValidator(t *testing.T) { }, MaxLen: 2, } - assert.NoError(t, maxLenSchema.Compile()) + assert.NoError(t, maxLenSchema.Compile(rc), "maxLenSchema compile error") - testCases := []struct { + cases := []struct { Name string Schema *schema.Schema Base, Change, Expect map[string]interface{} @@ -72,8 +77,8 @@ func TestSchemaValidator(t *testing.T) { }, } - for i := range testCases { - tc := testCases[i] + for i := range cases { + tc := cases[i] t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/schema/string.go b/schema/string.go index 72d32176..5efd04ad 100644 --- a/schema/string.go +++ b/schema/string.go @@ -17,7 +17,7 @@ type String struct { } // Compile compiles and validate regexp if any. -func (v *String) Compile() (err error) { +func (v *String) Compile(rc ReferenceChecker) (err error) { if v.Regexp != "" { // Compile and cache regexp, report any compilation error. if v.re, err = regexp.Compile(v.Regexp); err != nil { @@ -29,6 +29,11 @@ func (v *String) Compile() (err error) { // Validate validates and normalize string based value. func (v String) Validate(value interface{}) (interface{}, error) { + // Pre-check that compilation was successful. + if v.Regexp != "" && v.re == nil { + return nil, errors.New("not successfully compiled") + } + s, ok := value.(string) if !ok { return nil, errors.New("not a string") diff --git a/schema/string_test.go b/schema/string_test.go index 46aad56a..87e24a22 100644 --- a/schema/string_test.go +++ b/schema/string_test.go @@ -29,15 +29,15 @@ func TestStringValidator(t *testing.T) { assert.EqualError(t, err, "not one of [bar, baz]") assert.Nil(t, s) v := String{Regexp: "^f.o$"} - assert.NoError(t, v.Compile()) + assert.NoError(t, v.Compile(nil)) s, err = v.Validate("foo") assert.NoError(t, err) assert.Equal(t, "foo", s) v = String{Regexp: "^bar$"} - assert.NoError(t, v.Compile()) + assert.NoError(t, v.Compile(nil)) s, err = v.Validate("foo") assert.EqualError(t, err, "does not match ^bar$") assert.Nil(t, s) v = String{Regexp: "^bar["} - assert.EqualError(t, v.Compile(), "invalid regexp: error parsing regexp: missing closing ]: `[`") + assert.EqualError(t, v.Compile(nil), "invalid regexp: error parsing regexp: missing closing ]: `[`") } diff --git a/schema/time.go b/schema/time.go index 4a4b56bb..03208460 100644 --- a/schema/time.go +++ b/schema/time.go @@ -56,7 +56,7 @@ type Time struct { } // Compile the time formats. -func (v *Time) Compile() (err error) { +func (v *Time) Compile(rc ReferenceChecker) error { if len(v.TimeLayouts) == 0 { // default layouts to all formats. v.layouts = formats diff --git a/schema/time_test.go b/schema/time_test.go index a304bb63..6231abea 100644 --- a/schema/time_test.go +++ b/schema/time_test.go @@ -10,7 +10,7 @@ import ( func TestTimeValidate(t *testing.T) { now := time.Now().Truncate(time.Minute).UTC() timeT := Time{} - err := timeT.Compile() + err := timeT.Compile(nil) assert.NoError(t, err) for _, f := range formats { v, err := timeT.Validate(now.Format(f)) @@ -33,7 +33,7 @@ func TestTimeSpecificLayoutList(t *testing.T) { testList := []string{time.RFC1123Z, time.RFC822Z, time.RFC3339} // test for same list in reverse timeT := Time{TimeLayouts: []string{time.RFC3339, time.RFC822Z, time.RFC1123Z}} - err := timeT.Compile() + err := timeT.Compile(nil) assert.NoError(t, err) // expect no errors for _, f := range testList { @@ -48,7 +48,7 @@ func TestTimeForTimeLayoutFailure(t *testing.T) { testList := []string{time.ANSIC} // configure for RFC3339 time timeT := Time{TimeLayouts: []string{time.RFC3339}} - err := timeT.Compile() + err := timeT.Compile(nil) assert.NoError(t, err) // expect an error for _, f := range testList {