From 30e70b60708f9fe435589d7354b9ecd5d60a8c9b Mon Sep 17 00:00:00 2001 From: Yifan Date: Fri, 9 Sep 2022 20:32:05 -0700 Subject: [PATCH] pkg/server/*: Add graphql API for create, update, delete. --- README.md | 10 ++- pkg/model/book.go | 27 ++++++ pkg/server/schema.go | 115 +++++++++++++++++++++++- pkg/server/server.go | 156 ++++++++++++++++++++++++++++++--- pkg/storage/mongodb/storage.go | 8 +- 5 files changed, 298 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 54c59f8..c5f7a8b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Parser/Exporter: - [x] List parsers and exporters - [x] MongoDB exporter - [x] JSON parser +- [ ] One click export from Kindle app Server Backend: - [x] Database storage @@ -70,10 +71,11 @@ Server Backend: - [x] Timestamp(created, last modified) - [ ] Server REST API? - [x] Graphql API READ -- [ ] Graphql API Create -- [ ] Graphql API Update -- [ ] Graphql API Delete -- [ ] Handle Graphql null fields +- [x] Graphql API Create +- [x] Graphql API Update +- [x] Graphql API Delete +- [ ] Handle Graphql null fields? +- [ ] Graphql API tests, mocked storage App: - [ ] Search by tags, keywords, book, author diff --git a/pkg/model/book.go b/pkg/model/book.go index 4aaea42..d083107 100644 --- a/pkg/model/book.go +++ b/pkg/model/book.go @@ -5,6 +5,11 @@ Copyright © 2022 Yifan Gu package model +import ( + "errors" + "fmt" +) + const ( // MarkTypeHighlight is a highlight marking. MarkTypeHighlight = "HIGHLIGHT" @@ -12,6 +17,13 @@ const ( MarkTypeNote = "NOTE" ) +var ( + typeMaps = map[string]struct{}{ + MarkTypeHighlight: struct{}{}, + MarkTypeNote: struct{}{}, + } +) + // Book defines the details of a Book object, which also contains a list of marks. type Book struct { Title string `json:"title"` @@ -40,3 +52,18 @@ type Location struct { Page *int `json:"page,omitempty"` Location *int `json:"location,omitempty"` } + +func isSupportedType(typ string) bool { + _, ok := typeMaps[typ] + return ok +} + +func ValidateMark(m *Mark) error { + if !isSupportedType(m.Type) { + return errors.New(fmt.Sprintf("Type %v is not supported", m.Type)) + } + if m.Data == "" && m.UserNote == "" { + return errors.New("Expect 'data' or 'note' to be set") + } + return nil +} diff --git a/pkg/server/schema.go b/pkg/server/schema.go index 464c74f..bf595ec 100644 --- a/pkg/server/schema.go +++ b/pkg/server/schema.go @@ -72,6 +72,23 @@ var int64Type = graphql.NewScalar(graphql.ScalarConfig{ }, }) +var locationInputType = graphql.NewInputObject( + graphql.InputObjectConfig{ + Name: "LocationInput", + Fields: graphql.InputObjectConfigFieldMap{ + "chapter": &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + "page": &graphql.InputObjectFieldConfig{ + Type: graphql.Int, + }, + "location": &graphql.InputObjectFieldConfig{ + Type: graphql.Int, + }, + }, + }, +) + var locationType = graphql.NewObject( graphql.ObjectConfig{ Name: "Location", @@ -162,6 +179,9 @@ func (s *server) graphqlQueryType() *graphql.Object { "tags": &graphql.ArgumentConfig{ Type: graphql.NewList(graphql.String), }, + "location": &graphql.ArgumentConfig{ + Type: locationInputType, + }, "createdBefore": &graphql.ArgumentConfig{ Type: graphql.Int, }, @@ -185,10 +205,103 @@ func (s *server) graphqlQueryType() *graphql.Object { ) } +func (s *server) graphqlMutationType() *graphql.Object { + return graphql.NewObject( + graphql.ObjectConfig{ + Name: "Mutation", + Fields: graphql.Fields{ + // Create a new mark + // http://localhost:11212/marks?query=mutation+_{createOne(type:"",title:"",author:"",data:"",note:"",tags:[]){type,title,author,data,note,tags}} + "createOne": &graphql.Field{ + Type: markType, + Description: "Create a new mark", + Args: graphql.FieldConfigArgument{ + "type": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), // TODO(yifan): Use Enum? + }, + "title": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + "author": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + "section": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "location": &graphql.ArgumentConfig{ + Type: locationInputType, + }, + "data": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "note": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "tags": &graphql.ArgumentConfig{ + Type: graphql.NewList(graphql.String), + }, + }, + Resolve: s.createOneMark, + }, + // Update a mark by id + // http://localhost:11212/marks?query=mutation+_{updateOne(id:1,type:"",title:"",author:"",data:"",note:"",tags:[]){type,title,author,data,note,tags}} + "updateOne": &graphql.Field{ + Type: markType, + Description: "Update a mark by its ID", + Args: graphql.FieldConfigArgument{ + "id": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + "type": &graphql.ArgumentConfig{ + Type: graphql.String, // TODO(yifan): Use Enum? + }, + "title": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "author": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "section": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "location": &graphql.ArgumentConfig{ + Type: locationInputType, + }, + "data": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "note": &graphql.ArgumentConfig{ + Type: graphql.String, + }, + "tags": &graphql.ArgumentConfig{ + Type: graphql.NewList(graphql.String), + }, + }, + Resolve: s.updateOneMarkByID, + }, + // Delete a mark by id + // http://localhost:11212/marks?query=mutation+_{delete(id:1,){type,title,author,data,note,tags}} + "deleteOne": &graphql.Field{ + Type: markType, + Description: "Delete a mark by its ID", + Args: graphql.FieldConfigArgument{ + "id": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: s.deleteOneMarkByID, + }, + }, + }, + ) + +} + func (s *server) graphqlSchema() graphql.Schema { schema, err := graphql.NewSchema( graphql.SchemaConfig{ - Query: s.graphqlQueryType(), + Query: s.graphqlQueryType(), + Mutation: s.graphqlMutationType(), }, ) if err != nil { diff --git a/pkg/server/server.go b/pkg/server/server.go index 1acfd67..0b03658 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -13,6 +13,7 @@ import ( "github.com/graphql-go/graphql" "github.com/yifan-gu/blueNote/pkg/config" + "github.com/yifan-gu/blueNote/pkg/model" "github.com/yifan-gu/blueNote/pkg/storage" "github.com/yifan-gu/blueNote/pkg/util" "go.mongodb.org/mongo-driver/bson" @@ -47,35 +48,30 @@ func (s *server) resolveMarksQuery(p graphql.ResolveParams) (interface{}, error) limit, _ := p.Args["limit"].(int) id, idOK := p.Args["id"].(string) - typ, typOK := p.Args["type"].(string) - title, titleOK := p.Args["title"].(string) - author, authorOK := p.Args["author"].(string) - data, dataOK := p.Args["data"].(string) - note, noteOK := p.Args["note"].(string) - tags, tagsOK := p.Args["tags"].([]interface{}) - createdBefore, createdBeforeOK := p.Args["createdBefore"].(int) - createdAfter, createdAfterOK := p.Args["createdAfter"].(int) - lastModifiedBefore, lastModifiedBeforeOK := p.Args["lastModifiedBefore"].(int) - lastModifiedAfter, lastModifiedAfterOK := p.Args["lastModifiedAfter"].(int) - if idOK { filter["_id"] = id } + typ, typOK := p.Args["type"].(string) if typOK { filter["type"] = typ } + title, titleOK := p.Args["title"].(string) if titleOK { filter["title"] = bson.M{"$regex": title, "$options": "i"} } + author, authorOK := p.Args["author"].(string) if authorOK { filter["author"] = bson.M{"$regex": author, "$options": "i"} } + data, dataOK := p.Args["data"].(string) if dataOK { filter["data"] = bson.M{"$regex": data, "$options": "i"} } + note, noteOK := p.Args["note"].(string) if noteOK { filter["note"] = bson.M{"$regex": note, "$options": "i"} } + tags, tagsOK := p.Args["tags"].([]interface{}) if tagsOK { for _, tag := range tags { tagVal, ok := tag.(string) @@ -85,15 +81,19 @@ func (s *server) resolveMarksQuery(p graphql.ResolveParams) (interface{}, error) andCondition = append(andCondition, bson.M{"tags": bson.M{"$regex": tagVal, "$options": "i"}}) } } + createdBefore, createdBeforeOK := p.Args["createdBefore"].(int) if createdBeforeOK { andCondition = append(andCondition, bson.M{"createdAt": bson.M{"$lt": createdBefore}}) } + createdAfter, createdAfterOK := p.Args["createdAfter"].(int) if createdAfterOK { andCondition = append(andCondition, bson.M{"createdAt": bson.M{"$gt": createdAfter}}) } + lastModifiedBefore, lastModifiedBeforeOK := p.Args["lastModifiedBefore"].(int) if lastModifiedBeforeOK { andCondition = append(andCondition, bson.M{"lastModifiedAt": bson.M{"$lt": lastModifiedBefore}}) } + lastModifiedAfter, lastModifiedAfterOK := p.Args["lastModifiedAfter"].(int) if lastModifiedAfterOK { andCondition = append(andCondition, bson.M{"lastModifiedAt": bson.M{"$gt": lastModifiedAfter}}) } @@ -103,3 +103,137 @@ func (s *server) resolveMarksQuery(p graphql.ResolveParams) (interface{}, error) } return s.store.GetMarks(p.Context, filter, limit) } + +func (s *server) createOneMark(p graphql.ResolveParams) (interface{}, error) { + mark := &model.Mark{ + Type: p.Args["type"].(string), + Title: p.Args["title"].(string), + Author: p.Args["author"].(string), + } + section, sectionOK := p.Args["section"] + if sectionOK { + mark.Section = section.(string) + } + data, dataOK := p.Args["data"] + if dataOK { + mark.Data = data.(string) + } + note, noteOK := p.Args["note"] + if noteOK { + mark.UserNote = note.(string) + } + tags, tagsOK := p.Args["tags"].([]interface{}) + if tagsOK { + for i := range tags { + mark.Tags = append(mark.Tags, tags[i].(string)) + } + } + location, locationOK := p.Args["location"].(map[string]interface{}) + if locationOK { + createLocationField(mark, location) + } + + if err := model.ValidateMark(mark); err != nil { + return nil, err + } + id, err := s.store.CreateMark(p.Context, mark) + if err != nil { + return nil, err + } + mark.ID = id + return mark, nil +} + +func (s *server) updateOneMarkByID(p graphql.ResolveParams) (interface{}, error) { + id, idOK := p.Args["id"].(string) + if !idOK { + return nil, errors.New("No id is given") + } + marks, err := s.store.GetMarks(p.Context, bson.M{"_id": id}, 0) + if err != nil { + return nil, err + } + if len(marks) != 1 { + return nil, errors.New(fmt.Sprintf("Expect 1 mark, got %d", len(marks))) + } + update := marks[0] + typ, typOK := p.Args["type"].(string) + if typOK { + update.Type = typ + } + title, titleOK := p.Args["title"].(string) + if titleOK { + update.Title = title + } + author, authorOK := p.Args["author"].(string) + if authorOK { + update.Author = author + } + section, sectionOK := p.Args["section"] + if sectionOK { + update.Section = section.(string) + } + data, dataOK := p.Args["data"].(string) + if dataOK { + update.Data = data + } + note, noteOK := p.Args["note"].(string) + if noteOK { + update.UserNote = note + } + location, locationOK := p.Args["location"].(map[string]interface{}) + if locationOK { + createLocationField(update, location) + } + tags, tagsOK := p.Args["tags"].([]interface{}) + if tagsOK { + update.Tags = nil + for i := range tags { + update.Tags = append(update.Tags, tags[i].(string)) + } + } + + if err := model.ValidateMark(update); err != nil { + return nil, err + } + + if err := s.store.UpdateOneMark(p.Context, id, update); err != nil { + return nil, err + } + return update, nil +} + +func createLocationField(mark *model.Mark, location map[string]interface{}) { + mark.Location = &model.Location{} + for k, v := range location { + switch k { + case "chapter": + mark.Location.Chapter = v.(string) + case "page": + page := v.(int) + mark.Location.Page = &page + case "location": + location := v.(int) + mark.Location.Location = &location + } + } +} + +func (s *server) deleteOneMarkByID(p graphql.ResolveParams) (interface{}, error) { + id, idOK := p.Args["id"].(string) + if !idOK { + return nil, errors.New("No id is given") + } + + marks, err := s.store.GetMarks(p.Context, bson.M{"_id": id}, 0) + if err != nil { + return nil, err + } + if len(marks) != 1 { + return nil, errors.New(fmt.Sprintf("Expect 1 mark, got %d", len(marks))) + } + if err := s.store.DeleteOneMark(p.Context, id); err != nil { + return nil, err + } + return marks[0], nil +} diff --git a/pkg/storage/mongodb/storage.go b/pkg/storage/mongodb/storage.go index ee07069..a86f0bc 100644 --- a/pkg/storage/mongodb/storage.go +++ b/pkg/storage/mongodb/storage.go @@ -171,10 +171,10 @@ func (s *MongoDBStorage) UpdateOneMark(ctx context.Context, id string, update *m if err != nil { return errors.Wrap(err, "") } - if len(marks) != 0 { + if len(marks) != 1 { return errors.New(fmt.Sprintf("Expecting 1 mark for id %q, but saw %v", id, len(marks))) } - if _, err := s.coll.UpdateByID(ctx, id, constructUpdateFromMark(marks[0], update)); err != nil { + if _, err := s.coll.UpdateByID(ctx, objectID, constructUpdateFromMark(marks[0], update)); err != nil { return errors.Wrap(err, "") } return nil @@ -264,6 +264,9 @@ func parseFilterString(filter string) (bson.M, error) { // MarkToPersistentMark converts a Mark to a PersistentMark func MarkToPersistentMark(mark *model.Mark) (*PersistentMark, error) { + if err := model.ValidateMark(mark); err != nil { + return nil, err + } ret := &PersistentMark{ Type: mark.Type, Title: mark.Title, @@ -369,5 +372,6 @@ func constructUpdateFromMark(original, update *model.Mark) bson.M { if modified { b["lastModifiedAt"] = util.NowUnixMilli() } + return bson.M{"$set": b} }