From 468e5bedd51ea1bb09f88a0cfa1f47e4dca277de Mon Sep 17 00:00:00 2001 From: breel Date: Sun, 9 Aug 2020 10:41:37 -0600 Subject: [PATCH] impl entities and test --- server/entities.go | 80 +++++++-- server/entities_test.go | 368 +++++++++++++++++++++++++++++++++++++++ storage/driver/boltdb.go | 8 + storage/graph.go | 6 +- 4 files changed, 448 insertions(+), 14 deletions(-) create mode 100644 server/entities_test.go diff --git a/server/entities.go b/server/entities.go index 07fc39d..1a88689 100644 --- a/server/entities.go +++ b/server/entities.go @@ -11,6 +11,8 @@ import ( "path" "strings" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson/primitive" "gopkg.in/mgo.v2/bson" ) @@ -20,12 +22,30 @@ type shortEntity struct { } func (rest *REST) entities(w http.ResponseWriter, r *http.Request) { - if scope, err := rest.entityScope(r); err != nil { + scope, err := rest.entityScope(r) + if err != nil { rest.respNotFound(w) return - } else if r.Method != http.MethodGet && len(scope) > 1 { + } + if r.Method == http.MethodGet { + } else if r.Method == http.MethodPost && len(scope) != 0 { + rest.respNotFound(w) + return + } else if len(scope) > 1 { + if r.Method == http.MethodDelete { + q := r.URL.Query() + q.Set("delete", "") + r.URL.RawQuery = q.Encode() + } r.Method = http.MethodPatch } + if r.Method == http.MethodPatch && len(scope) == 1 { + r.Method = http.MethodPut + } + if r.Method == http.MethodPatch && len(scope) == 0 { + rest.respNotFound(w) + return + } switch r.Method { case http.MethodPut: rest.entitiesReplace(w, r) @@ -44,17 +64,17 @@ func (rest *REST) entities(w http.ResponseWriter, r *http.Request) { func (rest *REST) entitiesCreate(w http.ResponseWriter, r *http.Request) { scope := rest.scope(r) - entityScope, _ := rest.entityScope(r) one, err := rest.entityParse(r.Body) if err != nil { rest.respBadRequest(w, err.Error()) return } - one.ID = entityScope[0] + one.ID = uuid.New().String() if err := rest.g.Insert(r.Context(), scope.Namespace, one); err != nil { rest.respError(w, err) return } + r.URL.Path += "/" + one.ID rest.entitiesGet(w, r) } @@ -71,7 +91,7 @@ func (rest *REST) entitiesGet(w http.ResponseWriter, r *http.Request) { entityScope, _ := rest.entityScope(r) switch len(entityScope) { case 0: - rest.entitiesList(w, r) + rest.entitiesGetN(w, r) case 1: rest.entitiesGetOne(w, r) default: @@ -87,6 +107,11 @@ func (rest *REST) entitiesGetOne(w http.ResponseWriter, r *http.Request) { rest.respNotFound(w) return } + if _, ok := r.URL.Query()["light"]; !ok { + log.Println("TODO need to get all connections") + //http.Error(w, "not impl", http.StatusNotImplemented) + //return + } rest.respMap(w, entityScope[0], one) } @@ -128,7 +153,7 @@ func (rest *REST) entitiesGetOneSub(w http.ResponseWriter, r *http.Request) { rest.respMap(w, scope.EntityID, m[entityScope[0]]) } -func (rest *REST) entitiesList(w http.ResponseWriter, r *http.Request) { +func (rest *REST) entitiesGetN(w http.ResponseWriter, r *http.Request) { scope := rest.scope(r) entities, err := rest.g.List(r.Context(), scope.Namespace) if err != nil { @@ -163,9 +188,44 @@ func (rest *REST) entitiesReplace(w http.ResponseWriter, r *http.Request) { } func (rest *REST) entitiesUpdate(w http.ResponseWriter, r *http.Request) { - log.Println(r.Method) - log.Println(rest.entityScope(r)) - http.Error(w, "not impl", http.StatusNotImplemented) + entityScope, _ := rest.entityScope(r) + scope := rest.scope(r) + + _, del := r.URL.Query()["delete"] + + var m interface{} + if !del { + err := json.NewDecoder(r.Body).Decode(&m) + if err != nil { + rest.respBadRequest(w, err.Error()) + return + } + if mm, ok := m.(primitive.M); ok { + delete(mm, entity.ID) + m = mm + } + } + + key := strings.Join(entityScope[1:], ".") + var operation interface{} + operation = operator.Set{Key: key, Value: m} + if del { + operation = operator.Unset(key) + } + + err := rest.g.Update( + r.Context(), + scope.Namespace, + bson.M{entity.ID: scope.EntityID}, + operation, + ) + if err != nil { + rest.respError(w, err) + return + } + + r.URL.Path = path.Join("/", scope.EntityID) + rest.entitiesGet(w, r) } func (rest *REST) entityParse(r io.Reader) (entity.One, error) { @@ -177,7 +237,7 @@ func (rest *REST) entityParse(r io.Reader) (entity.One, error) { func (rest *REST) entityScope(r *http.Request) ([]string, error) { p := r.URL.Path if path.Dir(p) == path.Base(p) { - if r.Method == http.MethodGet { + if r.Method == http.MethodGet || r.Method == http.MethodPost { return []string{}, nil } return nil, errors.New("nothing specified") diff --git a/server/entities_test.go b/server/entities_test.go new file mode 100644 index 0000000..78a815e --- /dev/null +++ b/server/entities_test.go @@ -0,0 +1,368 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "local/dndex/storage/entity" + "net/http" + "net/http/httptest" + "path" + "strings" + "testing" +) + +func TestEntities(t *testing.T) { + rest, authit, clean := testREST(t) + defer clean() + + t.Run("create+get1+delete+404", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" + }) + + w = testEntitiesMethod(t, authit, rest, http.MethodGet, "/"+id, ``) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" + }) + if id2 != id { + t.Fatal(id, id2) + } + + w = testEntitiesMethod(t, authit, rest, http.MethodDelete, "/"+id, ``) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + + w = testEntitiesMethod(t, authit, rest, http.MethodGet, "/"+id, ``) + if w.Code != http.StatusNotFound { + t.Fatal(w.Code) + } + }) + + t.Run("get1 404", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodGet, "/abc123", ``) + if w.Code != http.StatusNotFound { + t.Fatal(w.Code) + } + }) + + t.Run("create+get0", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname", "attachments": {"abc": {}}, "connections": {"def": {"relationship": "ghi"}}}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" && len(one.Attachments) == 1 && len(one.Connections) == 1 && one.Connections["def"].Relationship == "ghi" + }) + + w = testEntitiesMethod(t, authit, rest, http.MethodGet, "/", ``) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + testEntitiesGetNResponse(t, w.Body, func(one shortEntity) bool { + return one.Name == "myname" && one.ID == id + }) + }) + + t.Run("create+replace+get1", func(t *testing.T) { + check := func(one entity.One) bool { + return one.Name == "myname" + } + + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, check) + + w = testEntitiesMethod(t, authit, rest, http.MethodPut, "/"+id, `{"name":"newname"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + + w = testEntitiesMethod(t, authit, rest, http.MethodGet, "/"+id, ``) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "newname" + }) + if id2 != id { + t.Fatal(id, id2) + } + }) + + t.Run("create+get.title", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname", "title": "mytitle"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" && one.Title == "mytitle" + }) + + w = testEntitiesMethod(t, authit, rest, http.MethodGet, "/"+id+"/title", ``) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + testEntitiesGetOneSubResponse(t, w.Body, func(v interface{}) bool { + return fmt.Sprint(v) == "mytitle" + }) + }) + + t.Run("create+update/replace", func(t *testing.T) { + for _, method := range []string{http.MethodPut, http.MethodPatch} { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname", "title": "mytitle"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" && one.Title == "mytitle" + }) + + w = testEntitiesMethod(t, authit, rest, method, "/"+id, `{"name": "newname"}`) + if w.Code != http.StatusOK { + t.Fatalf("%v: %s", w.Code, w.Body.Bytes()) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "newname" && one.Title == "" + }) + + if id != id2 { + t.Fatal(id, id2) + } + } + }) + + t.Run("create+update/replace.name", func(t *testing.T) { + for _, method := range []string{http.MethodPut, http.MethodPatch} { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname", "title": "mytitle"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" && one.Title == "mytitle" + }) + + w = testEntitiesMethod(t, authit, rest, method, "/"+id+"/name", `"newname"`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "newname" && one.Title == "mytitle" + }) + + if id != id2 { + t.Fatal(id, id2) + } + } + }) + + t.Run("create+update/replace.connection.abc.relationship", func(t *testing.T) { + for _, method := range []string{http.MethodPut, http.MethodPatch} { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"connections": {"abc": {"relationship": "def"}}}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Connections["abc"].Relationship == "def" + }) + + w = testEntitiesMethod(t, authit, rest, method, "/"+id+"/connections/abc/relationship", `"new"`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Connections["abc"].Relationship == "new" + }) + + if id != id2 { + t.Fatal(id, id2) + } + } + }) + + t.Run("create+update/replace.connection.abc", func(t *testing.T) { + for _, method := range []string{http.MethodPut, http.MethodPatch} { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"connections": {"abc": {"relationship": "def"}}}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Connections["abc"].Relationship == "def" + }) + + w = testEntitiesMethod(t, authit, rest, method, "/"+id+"/connections/abc", `{"relationship": "new"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Connections["abc"].Relationship == "new" + }) + + if id != id2 { + t.Fatal(id, id2) + } + } + }) + + t.Run("create+delete.name", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname", "title": "mytitle"}`) + if w.Code != http.StatusOK { + t.Fatal(w.Code) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" && one.Title == "mytitle" + }) + + w = testEntitiesMethod(t, authit, rest, http.MethodDelete, "/"+id+"/name", ``) + if w.Code != http.StatusOK { + t.Fatalf("%v: %s", w.Code, w.Body.Bytes()) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "" && one.Title == "mytitle" + }) + + if id != id2 { + t.Fatal(id, id2) + } + }) + + t.Run("create w connection.abc+delete.connections.abc", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"name":"myname", "title": "mytitle", "connections": {"abc": {"relationship": "good"}}}`) + if w.Code != http.StatusOK { + t.Fatalf("%v: %s", w.Code, w.Body.Bytes()) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + abc, ok := one.Connections["abc"] + return one.Name == "myname" && one.Title == "mytitle" && ok && abc.Relationship == "good" + }) + + w = testEntitiesMethod(t, authit, rest, http.MethodDelete, "/"+id+"/connections/abc", ``) + if w.Code != http.StatusOK { + t.Fatalf("%v: %s", w.Code, w.Body.Bytes()) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return one.Name == "myname" && one.Title == "mytitle" && len(one.Connections) == 0 + }) + + if id != id2 { + t.Fatal(id, id2) + } + }) + + t.Run("create w connection.abc.relationship+delete.connections.abc.relationship", func(t *testing.T) { + w := testEntitiesMethod(t, authit, rest, http.MethodPost, "/", `{"connections": {"abc": {"relationship": "good"}}}`) + if w.Code != http.StatusOK { + t.Fatalf("%v: %s", w.Code, w.Body.Bytes()) + } + id := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + abc, ok := one.Connections["abc"] + return ok && abc.Relationship == "good" + }) + + w = testEntitiesMethod(t, authit, rest, http.MethodDelete, "/"+id+"/connections/abc/relationship", ``) + if w.Code != http.StatusOK { + t.Fatalf("%v: %s", w.Code, w.Body.Bytes()) + } + id2 := testEntitiesGetOneResponse(t, w.Body, func(one entity.One) bool { + return len(one.Connections) == 1 && one.Connections["abc"].Relationship == "" + }) + + if id != id2 { + t.Fatal(id, id2) + } + }) +} + +func testEntitiesMethod(t *testing.T, authit func(*http.Request), rest *REST, method, p, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, p, strings.NewReader(body)) + if !strings.HasPrefix(r.URL.Path, "/entities") { + r.URL.Path = path.Join("/entities", r.URL.Path) + } + w := httptest.NewRecorder() + authit(r) + rest.scoped(rest.shift(rest.entities))(w, r) + return w +} + +func testEntitiesGetNResponse(t *testing.T, body io.Reader, check func(shortEntity) bool) { + var resp map[string][]shortEntity + if err := json.NewDecoder(body).Decode(&resp); err != nil { + t.Fatal(err) + } + if len(resp) != 1 { + t.Fatal("excess found in db") + } + for k := range resp { + contents := resp[k] + if len(contents) < 1 { + t.Fatal(len(contents)) + } + for i := range contents { + if check(contents[i]) { + return + } + } + t.Fatal(contents) + } +} + +func testEntitiesGetOneResponse(t *testing.T, body io.Reader, check func(entity.One) bool) string { + b, err := ioutil.ReadAll(body) + if err != nil { + t.Fatal(err) + } + var resp map[string]entity.One + if err := json.Unmarshal(b, &resp); err != nil { + t.Fatalf("%v: %s", err, b) + } + if len(resp) != 1 { + t.Fatal(len(resp)) + } + for k := range resp { + one := resp[k] + if one.ID != k { + t.Fatal(k, one.ID) + } + if one.Modified == 0 { + t.Fatal(one.Modified) + } + if !check(one) { + t.Fatal(one) + } + return one.ID + } + panic("somehow no keys found") +} + +func testEntitiesGetOneSubResponse(t *testing.T, body io.Reader, check func(interface{}) bool) { + b, err := ioutil.ReadAll(body) + if err != nil { + t.Fatal(err) + } + var resp map[string]interface{} + if err := json.Unmarshal(b, &resp); err != nil { + t.Fatalf("%v: %s", err, b) + } + if len(resp) != 1 { + t.Fatal(len(resp)) + } + for k := range resp { + one := resp[k] + if !check(one) { + t.Fatal(one) + } + return + } + panic("somehow no keys found") +} diff --git a/storage/driver/boltdb.go b/storage/driver/boltdb.go index 465319e..303780c 100644 --- a/storage/driver/boltdb.go +++ b/storage/driver/boltdb.go @@ -310,6 +310,14 @@ func applySet(doc, operator bson.M) (bson.M, error) { if k == entity.ID { continue } + if k == "." { + m, ok := v.(bson.M) + if !ok { + return nil, errors.New("cannot assign non-map to doc") + } + doc = m + return doc, nil + } nesting := strings.Split(k, ".") if len(nesting) > 1 { mInterface, ok := doc[nesting[0]] diff --git a/storage/graph.go b/storage/graph.go index 2f1d067..0d26035 100644 --- a/storage/graph.go +++ b/storage/graph.go @@ -74,10 +74,8 @@ func (g Graph) Insert(ctx context.Context, namespace string, one entity.One) err if one.ID == "" { return errors.New("cannot create document without id") } - if ones, err := g.ListCaseInsensitive(ctx, namespace, one.ID); err != nil { - return err - } else if len(ones) > 0 { - return fmt.Errorf("collision on primary key when case insensitive: cannot create %q because %+v exists", one.ID, ones) + if one, err := g.Get(ctx, namespace, one.ID); err == nil { + return fmt.Errorf("collision on primary key when case insensitive: cannot create %q because %+v exists", one.ID, one) } return g.driver.Insert(ctx, namespace, one) }