diff --git a/controllers/rest/api_test.go b/controllers/rest/api_test.go index f21958ba..b91f611e 100644 --- a/controllers/rest/api_test.go +++ b/controllers/rest/api_test.go @@ -269,3 +269,50 @@ func TestRESTAPI_Initialize_ViewAddedToGet(t *testing.T) { } os.Remove("test.db") } + +func TestRESTAPI_Initialize_GetEntityBySequenceNuber(t *testing.T) { + os.Remove("test.db") + time.Sleep(1 * time.Second) + e := echo.New() + tapi := api.RESTAPI{} + _, err := api.Initialize(e, &tapi, "./fixtures/blog-create-batch.yaml") + if err != nil { + t.Fatalf("unexpected error '%s'", err) + } + mockBlog := &[3]Blog{ + {ID: "1asdas3", Title: "Blog 1", Url: "www.testBlog1.com"}, + {ID: "2gf233", Title: "Blog 2", Url: "www.testBlog2.com"}, + {ID: "3dgff3", Title: "Blog 3", Url: "www.testBlog3.com"}, + } + reqBytes, err := json.Marshal(mockBlog) + if err != nil { + t.Fatalf("error setting up request %s", err) + } + body := bytes.NewReader(reqBytes) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/blogs", body) + e.ServeHTTP(resp, req) + //confirm that the response is 201 + if resp.Result().StatusCode != http.StatusCreated { + t.Errorf("expected the response code to be %d, got %d", http.StatusCreated, resp.Result().StatusCode) + } + + blogEntity, err := api.GetContentBySequenceNumber(tapi.Application.EventRepository(), "3dgff3", 4) + if err != nil { + t.Fatal(err) + } + + mapEntity, ok := blogEntity.Property.(map[string]interface{}) + + if !ok { + t.Fatal("expected the properties of the blog entity to be mapable") + } + if mapEntity["title"] != "Blog 3" { + t.Errorf("expected the title to be %s got %s", "Blog 3", mapEntity["title"]) + } + + if blogEntity.SequenceNo != int64(1) { + t.Errorf("expected the sequence number to be %d got %d", blogEntity.SequenceNo, 1) + } + os.Remove("test.db") +} diff --git a/controllers/rest/fixtures/blog-pk-guid-title.yaml b/controllers/rest/fixtures/blog-pk-guid-title.yaml new file mode 100644 index 00000000..410096c3 --- /dev/null +++ b/controllers/rest/fixtures/blog-pk-guid-title.yaml @@ -0,0 +1,582 @@ +openapi: 3.0.3 +info: + title: Blog + description: Blog example + version: 1.0.0 +servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 +x-weos-config: + event-source: + - title: default + driver: service + endpoint: https://prod1.weos.sh/events/v1 + - title: event + driver: sqlite3 + database: test.db + database: + driver: sqlite3 + database: test.db + databases: + - title: default + driver: sqlite3 + database: test.db + rest: + middleware: + - RequestID + - Recover + - ZapLogger +components: + schemas: + Category: + type: object + properties: + title: + type: string + description: + type: string + required: + - title + x-identifier: + - title + Author: + type: object + properties: + id: + type: string + format: ksuid + firstName: + type: string + lastName: + type: string + email: + type: string + format: email + required: + - firstName + - lastName + x-identifier: + - id + - email + Blog: + type: object + properties: + id: + type: string + url: + type: string + format: uri + title: + type: string + description: + type: string + status: + type: string + nullable: true + enum: + - null + - unpublished + - published + image: + type: string + format: byte + categories: + type: array + items: + $ref: "#/components/schemas/Post" + posts: + type: array + items: + $ref: "#/components/schemas/Category" + lastUpdated: + type: string + format: date-time + created: + type: string + format: date-time + required: + - title + - url + x-identifier: + - guid + - title + Post: + type: object + properties: + title: + type: string + description: + type: string + author: + $ref: "#/components/schemas/Author" + created: + type: string + format: date-time +paths: + /health: + summary: Health Check + get: + x-controller: HealthCheck + responses: + 200: + description: Health Response + 500: + description: API Internal Error + /blogs: + parameters: + - in: header + name: someHeader + schema: + type: string + - in: header + name: someOtherHeader + schema: + type: string + x-context-name: soh + - in: header + name: X-Account-Id + schema: + type: string + x-context-name: AccountID + - in: query + name: q + schema: + type: string + post: + operationId: Add Blog + summary: Create Blog + requestBody: + description: Blog info that is submitted + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Blog" + responses: + 201: + description: Add Blog to Aggregator + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + get: + operationId: Get Blogs + summary: Get List of Blogs + parameters: + - in: query + name: filters + schema: + type: array + items: + type: object + properties: + field: + type: string + operator: + type: string + values: + type: array + items: + type: string + + required: false + description: query string + x-context: + filters: + - field: status + operator: eq + values: + - Active + - field: lastUpdated + operator: between + values: + - 2021-12-17 15:46:00 + - 2021-12-18 15:46:00 + - field: categories + operator: in + values: + - Technology + - Javascript + sorts: + - field: title + order: asc + page: 1 + limit: 10 + responses: + 200: + description: List of blogs + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Blog" + /blogs/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + summary: Get Blog by id + operationId: Get Blog + responses: + 200: + description: Blog details without any supporting collections + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + summary: Update blog details + operationId: Update Blog + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + responses: + 200: + description: Update Blog + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + summary: Delete blog + operationId: Delete Blog + responses: + 200: + description: Blog Deleted + + /posts/: + post: + operationId: Create Blog Post + summary: Create Blog Post + requestBody: + description: Post details + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Post" + responses: + 201: + description: Post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + put: + operationId: Import Blog Posts + summary: Import Blog Posts + requestBody: + description: List of posts to import + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + application/x-www-form-urlencoded: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + responses: + 201: + description: Post + get: + operationId: Get Posts + summary: Get a blog's list of posts + parameters: + - in: query + name: q + schema: + type: string + required: false + description: query string + responses: + 200: + description: List of blog posts + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Post" + + /posts/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Get blog post by id + responses: + 200: + description: Get blog post information + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Update post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + responses: + 200: + description: Get blog post information + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Delete post + responses: + 200: + description: Delete post + + + /categories/: + post: + operationId: Create Blog Category + summary: Create Blog Category + requestBody: + description: Post details + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Category" + responses: + 201: + description: Post + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + get: + operationId: Get Categories + summary: Get a blog's list of categories + parameters: + - in: query + name: q + schema: + type: string + required: false + description: query string + responses: + 200: + description: List of blog categories + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Category" + + /categories/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Get blog category by id + responses: + 200: + description: Get blog category information + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Update category + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + responses: + 200: + description: Get blog category information + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Delete category + responses: + 200: + description: Delete category + + /authors/: + post: + operationId: Create Blog Author + summary: Create Blog Author + requestBody: + description: Author details + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Author" + responses: + 201: + description: Post + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + get: + operationId: Get Authors + summary: Get a blog's list of authors + parameters: + - in: query + name: q + schema: + type: string + required: false + description: query string + responses: + 200: + description: List of blog authors + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Author" + + /authors/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Get Author by id + responses: + 200: + description: Get author information + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Update Author details + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + responses: + 200: + description: Author details + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Delete author + responses: + 200: + description: Delete author diff --git a/controllers/rest/fixtures/blog-pk-id.yaml b/controllers/rest/fixtures/blog-pk-id.yaml new file mode 100644 index 00000000..7116f94e --- /dev/null +++ b/controllers/rest/fixtures/blog-pk-id.yaml @@ -0,0 +1,581 @@ +openapi: 3.0.3 +info: + title: Blog + description: Blog example + version: 1.0.0 +servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 +x-weos-config: + event-source: + - title: default + driver: service + endpoint: https://prod1.weos.sh/events/v1 + - title: event + driver: sqlite3 + database: test.db + database: + driver: sqlite3 + database: test.db + databases: + - title: default + driver: sqlite3 + database: test.db + rest: + middleware: + - RequestID + - Recover + - ZapLogger +components: + schemas: + Category: + type: object + properties: + title: + type: string + description: + type: string + required: + - title + x-identifier: + - title + Author: + type: object + properties: + id: + type: string + format: ksuid + firstName: + type: string + lastName: + type: string + email: + type: string + format: email + required: + - firstName + - lastName + x-identifier: + - id + - email + Blog: + type: object + properties: + id: + type: string + url: + type: string + format: uri + title: + type: string + description: + type: string + status: + type: string + nullable: true + enum: + - null + - unpublished + - published + image: + type: string + format: byte + categories: + type: array + items: + $ref: "#/components/schemas/Post" + posts: + type: array + items: + $ref: "#/components/schemas/Category" + lastUpdated: + type: string + format: date-time + created: + type: string + format: date-time + required: + - title + - url + x-identifier: + - id + Post: + type: object + properties: + title: + type: string + description: + type: string + author: + $ref: "#/components/schemas/Author" + created: + type: string + format: date-time +paths: + /health: + summary: Health Check + get: + x-controller: HealthCheck + responses: + 200: + description: Health Response + 500: + description: API Internal Error + /blogs: + parameters: + - in: header + name: someHeader + schema: + type: string + - in: header + name: someOtherHeader + schema: + type: string + x-context-name: soh + - in: header + name: X-Account-Id + schema: + type: string + x-context-name: AccountID + - in: query + name: q + schema: + type: string + post: + operationId: Add Blog + summary: Create Blog + requestBody: + description: Blog info that is submitted + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Blog" + responses: + 201: + description: Add Blog to Aggregator + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + get: + operationId: Get Blogs + summary: Get List of Blogs + parameters: + - in: query + name: filters + schema: + type: array + items: + type: object + properties: + field: + type: string + operator: + type: string + values: + type: array + items: + type: string + + required: false + description: query string + x-context: + filters: + - field: status + operator: eq + values: + - Active + - field: lastUpdated + operator: between + values: + - 2021-12-17 15:46:00 + - 2021-12-18 15:46:00 + - field: categories + operator: in + values: + - Technology + - Javascript + sorts: + - field: title + order: asc + page: 1 + limit: 10 + responses: + 200: + description: List of blogs + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Blog" + /blogs/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + summary: Get Blog by id + operationId: Get Blog + responses: + 200: + description: Blog details without any supporting collections + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + summary: Update blog details + operationId: Update Blog + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + responses: + 200: + description: Update Blog + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + summary: Delete blog + operationId: Delete Blog + responses: + 200: + description: Blog Deleted + + /posts/: + post: + operationId: Create Blog Post + summary: Create Blog Post + requestBody: + description: Post details + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Post" + responses: + 201: + description: Post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + put: + operationId: Import Blog Posts + summary: Import Blog Posts + requestBody: + description: List of posts to import + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + application/x-www-form-urlencoded: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + responses: + 201: + description: Post + get: + operationId: Get Posts + summary: Get a blog's list of posts + parameters: + - in: query + name: q + schema: + type: string + required: false + description: query string + responses: + 200: + description: List of blog posts + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Post" + + /posts/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Get blog post by id + responses: + 200: + description: Get blog post information + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Update post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + responses: + 200: + description: Get blog post information + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Delete post + responses: + 200: + description: Delete post + + + /categories/: + post: + operationId: Create Blog Category + summary: Create Blog Category + requestBody: + description: Post details + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Category" + responses: + 201: + description: Post + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + get: + operationId: Get Categories + summary: Get a blog's list of categories + parameters: + - in: query + name: q + schema: + type: string + required: false + description: query string + responses: + 200: + description: List of blog categories + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Category" + + /categories/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Get blog category by id + responses: + 200: + description: Get blog category information + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Update category + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + responses: + 200: + description: Get blog category information + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Delete category + responses: + 200: + description: Delete category + + /authors/: + post: + operationId: Create Blog Author + summary: Create Blog Author + requestBody: + description: Author details + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Author" + responses: + 201: + description: Post + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + get: + operationId: Get Authors + summary: Get a blog's list of authors + parameters: + - in: query + name: q + schema: + type: string + required: false + description: query string + responses: + 200: + description: List of blog authors + content: + application/json: + schema: + type: object + properties: + total: + type: integer + page: + type: integer + items: + $ref: "#/components/schemas/Author" + + /authors/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Get Author by id + responses: + 200: + description: Get author information + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + put: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Update Author details + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + responses: + 200: + description: Author details + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + delete: + parameters: + - in: path + name: id + schema: + type: string + required: true + summary: Delete author + responses: + 200: + description: Delete author diff --git a/controllers/rest/utils.go b/controllers/rest/utils.go index 6fce7808..009e946d 100644 --- a/controllers/rest/utils.go +++ b/controllers/rest/utils.go @@ -4,14 +4,15 @@ import ( "bufio" "bytes" "fmt" - "github.com/labstack/echo/v4" - "github.com/labstack/gommon/log" - "github.com/wepala/weos-service/model" "io" "io/ioutil" "net/http" "strconv" "strings" + + "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" + "github.com/wepala/weos-service/model" ) //LoadHttpRequestFixture wrapper around the test helper to make it easier to use it with test table @@ -197,3 +198,13 @@ func SplitEtag(Etag string) (string, string) { seqNo := result[1] return weosID, seqNo } + +func GetContentBySequenceNumber(eventRepository model.EventRepository, id string, sequence_no int64) (*model.ContentEntity, error) { + entity := &model.ContentEntity{} + events, err := eventRepository.GetByAggregateAndSequenceRange(id, 0, sequence_no) + if err != nil { + return nil, err + } + err = entity.ApplyChanges(events) + return entity, err +} diff --git a/controllers/rest/weos_mocks_test.go b/controllers/rest/weos_mocks_test.go index 356c2439..f46f972a 100644 --- a/controllers/rest/weos_mocks_test.go +++ b/controllers/rest/weos_mocks_test.go @@ -6,6 +6,7 @@ package rest_test import ( "context" "database/sql" + context2 "github.com/wepala/weos-service/context" weos "github.com/wepala/weos-service/model" "gorm.io/gorm" "net/http" @@ -1692,6 +1693,9 @@ var _ weos.Projection = &ProjectionMock{} // // // make and configure a mocked model.Projection // mockedProjection := &ProjectionMock{ +// GetByKeyFunc: func(ctxt context.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { +// panic("mock out the GetByKey method") +// }, // GetContentEntityFunc: func(ctx context.Context, weosID string) (*model.ContentEntity, error) { // panic("mock out the GetContentEntity method") // }, @@ -1708,6 +1712,9 @@ var _ weos.Projection = &ProjectionMock{} // // } type ProjectionMock struct { + // GetByKeyFunc mocks the GetByKey method. + GetByKeyFunc func(ctxt context.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) + // GetContentEntityFunc mocks the GetContentEntity method. GetContentEntityFunc func(ctx context.Context, weosID string) (*weos.ContentEntity, error) @@ -1719,6 +1726,15 @@ type ProjectionMock struct { // calls tracks calls to the methods. calls struct { + // GetByKey holds details about calls to the GetByKey method. + GetByKey []struct { + // Ctxt is the ctxt argument value. + Ctxt context.Context + // ContentType is the contentType argument value. + ContentType *context2.ContentType + // Identifiers is the identifiers argument value. + Identifiers map[string]interface{} + } // GetContentEntity holds details about calls to the GetContentEntity method. GetContentEntity []struct { // Ctx is the ctx argument value. @@ -1735,11 +1751,51 @@ type ProjectionMock struct { Ctx context.Context } } + lockGetByKey sync.RWMutex lockGetContentEntity sync.RWMutex lockGetEventHandler sync.RWMutex lockMigrate sync.RWMutex } +// GetByKey calls GetByKeyFunc. +func (mock *ProjectionMock) GetByKey(ctxt context.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + if mock.GetByKeyFunc == nil { + panic("ProjectionMock.GetByKeyFunc: method is nil but Projection.GetByKey was just called") + } + callInfo := struct { + Ctxt context.Context + ContentType *context2.ContentType + Identifiers map[string]interface{} + }{ + Ctxt: ctxt, + ContentType: contentType, + Identifiers: identifiers, + } + mock.lockGetByKey.Lock() + mock.calls.GetByKey = append(mock.calls.GetByKey, callInfo) + mock.lockGetByKey.Unlock() + return mock.GetByKeyFunc(ctxt, contentType, identifiers) +} + +// GetByKeyCalls gets all the calls that were made to GetByKey. +// Check the length with: +// len(mockedProjection.GetByKeyCalls()) +func (mock *ProjectionMock) GetByKeyCalls() []struct { + Ctxt context.Context + ContentType *context2.ContentType + Identifiers map[string]interface{} +} { + var calls []struct { + Ctxt context.Context + ContentType *context2.ContentType + Identifiers map[string]interface{} + } + mock.lockGetByKey.RLock() + calls = mock.calls.GetByKey + mock.lockGetByKey.RUnlock() + return calls +} + // GetContentEntity calls GetContentEntityFunc. func (mock *ProjectionMock) GetContentEntity(ctx context.Context, weosID string) (*weos.ContentEntity, error) { if mock.GetContentEntityFunc == nil { diff --git a/end2end_test.go b/end2end_test.go index a52b8c7b..6d0d7743 100644 --- a/end2end_test.go +++ b/end2end_test.go @@ -538,10 +538,14 @@ func theHeaderShouldBe(key, value string) error { if seqNoEtag == "" { return fmt.Errorf("expected the Etag to contain a sequence no, got %s", seqNoEtag) } + + if seqNoEtag != strings.Split(value, ".")[1] { + return fmt.Errorf("expected the Etag to contain a sequence no %s, got %s", strings.Split(value, ".")[1], seqNoEtag) + } return nil } - headers := rec.HeaderMap + headers := rec.Result().Header val := []string{} for k, v := range headers { diff --git a/model/content_entity.go b/model/content_entity.go index 4c73c04d..34b05075 100644 --- a/model/content_entity.go +++ b/model/content_entity.go @@ -131,6 +131,25 @@ func (w *ContentEntity) FromSchemaWithValues(ctx context.Context, schema *openap return w, w.ApplyChanges([]*Event{event}) } +func (w *ContentEntity) Update(ctx context.Context, existingPayload json.RawMessage, updatedPayload json.RawMessage) (*ContentEntity, error) { + contentType := weosContext.GetContentType(ctx) + + w.FromSchema(ctx, contentType.Schema) + + err := json.Unmarshal(existingPayload, &w.BasicEntity) + if err != nil { + return nil, err + } + err = json.Unmarshal(existingPayload, &w.Property) + if err != nil { + return nil, err + } + + event := NewEntityEvent("update", w, w.ID, updatedPayload) + w.NewChange(event) + return w, w.ApplyChanges([]*Event{event}) +} + //GetString returns the string property value stored of a given the property name func (w *ContentEntity) GetString(name string) string { if w.Property == nil { @@ -210,6 +229,12 @@ func (w *ContentEntity) ApplyChanges(changes []*Event) error { return err } w.User.BasicEntity.ID = change.Meta.User + case "update": + err := json.Unmarshal(change.Payload, &w.Property) + if err != nil { + return NewDomainError("invalid: unable to get ID from payload", change.Meta.EntityType, w.ID, err) + } + w.User.BasicEntity.ID = change.Meta.User } } diff --git a/model/content_entity_test.go b/model/content_entity_test.go index be7a74a9..683f0f39 100644 --- a/model/content_entity_test.go +++ b/model/content_entity_test.go @@ -116,3 +116,67 @@ func TestContentEntity_IsValid(t *testing.T) { } }) } + +func TestContentEntity_Update(t *testing.T) { + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("../controllers/rest/fixtures/blog.yaml") + if err != nil { + t.Fatalf("unexpected error occured '%s'", err) + } + var contentType string + var contentTypeSchema *openapi3.SchemaRef + contentType = "Blog" + contentTypeSchema = swagger.Components.Schemas[contentType] + ctx := context.Background() + ctx = context.WithValue(ctx, weosContext.CONTENT_TYPE, &weosContext.ContentType{ + Name: contentType, + Schema: contentTypeSchema.Value, + }) + ctx = context.WithValue(ctx, weosContext.USER_ID, "123") + + mockBlog := &Blog{ + Title: "test 1", + Description: "lorem ipsum", + Url: "www.ShaniahsBlog.com", + } + payload, err := json.Marshal(mockBlog) + if err != nil { + t.Fatalf("unexpected error marshalling payload '%s'", err) + } + + existingEntity, err := new(model.ContentEntity).FromSchemaWithValues(ctx, swagger.Components.Schemas["Blog"].Value, payload) + if err != nil { + t.Fatalf("unexpected error instantiating content entity '%s'", err) + } + + existingEntityPayload, err := json.Marshal(existingEntity) + if err != nil { + t.Fatalf("unexpected error marshalling content entity '%s'", err) + } + + if existingEntity.GetString("Title") != "test 1" { + t.Errorf("expected the title to be '%s', got '%s'", "test 1", existingEntity.GetString("Title")) + } + + input := &Blog{ + Title: "updated title", + Description: "updated desc", + } + + updatedPayload, err := json.Marshal(input) + if err != nil { + t.Fatalf("unexpected error marshalling update payload '%s'", err) + } + + updatedEntity, err := existingEntity.Update(ctx, existingEntityPayload, updatedPayload) + if err != nil { + t.Fatalf("unexpected error updating existing entity '%s'", err) + } + + if updatedEntity.GetString("Title") != "updated title" { + t.Errorf("expected the updated title to be '%s', got '%s'", "updated title", existingEntity.GetString("Title")) + } + + if updatedEntity.GetString("Description") != "updated desc" { + t.Errorf("expected the updated description to be '%s', got '%s'", "updated desc", existingEntity.GetString("Description")) + } +} diff --git a/model/domain_service.go b/model/domain_service.go index 2e99a4c1..421d30c9 100644 --- a/model/domain_service.go +++ b/model/domain_service.go @@ -2,11 +2,14 @@ package model import ( "encoding/json" + "strconv" + weosContext "github.com/wepala/weos-service/context" "golang.org/x/net/context" ) type DomainService struct { + Projection Repository eventRepository EventRepository } @@ -56,8 +59,108 @@ func (s *DomainService) CreateBatch(ctx context.Context, payload json.RawMessage } -func NewDomainService(ctx context.Context, eventRepository EventRepository) *DomainService { +//Update is used for a single payload. It gets an existing entity and updates it with the new payload +func (s *DomainService) Update(ctx context.Context, payload json.RawMessage, entityType string) (*ContentEntity, error) { + var updatedEntity *ContentEntity + existingEntity := &ContentEntity{} + var weosID string + contentType := weosContext.GetContentType(ctx) + + //Fetch the weosID from the payload + weosID, err := GetIDfromPayload(payload) + if err != nil { + return nil, err + } + + //If there is a weosID present use this + if weosID != "" { + seqNo, err := GetSeqfromPayload(payload) + if err != nil { + return nil, err + } + + existingEntity, err := s.GetContentEntity(ctx, weosID) + if err != nil { + return nil, NewDomainError("invalid: unexpected error fetching existing entity", entityType, weosID, err) + } + + entitySeqNo := strconv.Itoa(int(existingEntity.SequenceNo)) + + if seqNo != "" { + if seqNo != entitySeqNo { + return nil, NewDomainError("error updating entity. This is a stale item", entityType, weosID, nil) + } + } + + existingEntityPayload, err := json.Marshal(existingEntity) + if err != nil { + return nil, err + } + + updatedEntity, err = existingEntity.Update(ctx, existingEntityPayload, payload) + if err != nil { + return nil, err + } + + if ok := updatedEntity.IsValid(); !ok { + return nil, NewDomainError("unexpected error entity is invalid", entityType, updatedEntity.ID, nil) + } + + //If there is no weosID, use the id passed from the param + } else if weosID == "" { + var primaryKeys []string + identifiers := map[string]interface{}{} + + if contentType.Schema.Extensions["x-identifier"] != nil { + identifiersFromSchema := contentType.Schema.Extensions["x-identifier"].(json.RawMessage) + json.Unmarshal(identifiersFromSchema, &primaryKeys) + } + + if len(primaryKeys) == 0 { + primaryKeys = append(primaryKeys, "id") + } + + for _, pk := range primaryKeys { + ctxtIdentifier := ctx.Value(pk) + + if ctxtIdentifier == nil { + return nil, NewDomainError("invalid: no value provided for primary key", entityType, "", nil) + } + + identifiers[pk] = ctxtIdentifier + } + + entityInterface, err := s.GetByKey(ctx, contentType, identifiers) + if err != nil { + return nil, NewDomainError("invalid: unexpected error fetching existing entity", entityType, "", err) + } + + data, err := json.Marshal(entityInterface) + if err != nil { + return nil, err + } + + err = json.Unmarshal(data, &existingEntity) + if err != nil { + return nil, err + } + + updatedEntity, err = existingEntity.Update(ctx, data, payload) + if err != nil { + return nil, err + } + + if ok := updatedEntity.IsValid(); !ok { + return nil, NewDomainError("unexpected error entity is invalid", entityType, updatedEntity.ID, nil) + } + + } + return updatedEntity, nil +} + +func NewDomainService(ctx context.Context, eventRepository EventRepository, projections Projection) *DomainService { return &DomainService{ eventRepository: eventRepository, + Projection: projections, } } diff --git a/model/domain_service_test.go b/model/domain_service_test.go index e4935d2f..27a0935f 100644 --- a/model/domain_service_test.go +++ b/model/domain_service_test.go @@ -1,6 +1,7 @@ package model_test import ( + context3 "context" "encoding/json" "github.com/getkin/kin-openapi/openapi3" context2 "github.com/wepala/weos-service/context" @@ -44,7 +45,7 @@ func TestDomainService_Create(t *testing.T) { t.Fatalf("error converting content type to bytes %s", err) } - dService := model.NewDomainService(newContext, mockEventRepository) + dService := model.NewDomainService(newContext, mockEventRepository, nil) blog, err := dService.Create(newContext, reqBytes, entityType) if err != nil { @@ -73,7 +74,7 @@ func TestDomainService_Create(t *testing.T) { if err != nil { t.Fatalf("error converting content type to bytes %s", err) } - dService := model.NewDomainService(newContext, mockEventRepository) + dService := model.NewDomainService(newContext, mockEventRepository, nil) blog, err := dService.Create(newContext, reqBytes, entityType) if err.Error() != "entity property title required" { @@ -123,7 +124,7 @@ func TestDomainService_CreateBatch(t *testing.T) { t.Fatalf("error converting content type to bytes %s", err) } - dService := model.NewDomainService(newContext, mockEventRepository) + dService := model.NewDomainService(newContext, mockEventRepository, nil) blogs, err := dService.CreateBatch(newContext, reqBytes, entityType) if err != nil { @@ -143,3 +144,342 @@ func TestDomainService_CreateBatch(t *testing.T) { } }) } + +func TestDomainService_Update(t *testing.T) { + + //load open api spec + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("../controllers/rest/fixtures/blog.yaml") + if err != nil { + t.Fatalf("unexpected error occured '%s'", err) + } + var contentType string + var contentTypeSchema *openapi3.SchemaRef + contentType = "Blog" + contentTypeSchema = swagger.Components.Schemas[contentType] + newContext := context.Background() + newContext = context.WithValue(newContext, context2.CONTENT_TYPE, &context2.ContentType{ + Name: contentType, + Schema: contentTypeSchema.Value, + }) + + entityType := "Blog" + + existingPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": int64(1), "title": "blog 1", "description": "Description testing 1", "url": "www.TestBlog1.com"} + reqBytes, err := json.Marshal(existingPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + mockEventRepository := &EventRepositoryMock{ + PersistFunc: func(ctxt context.Context, entity model.AggregateInterface) error { + return nil + }, + } + + dService := model.NewDomainService(newContext, mockEventRepository, nil) + existingBlog, err := dService.Create(newContext, reqBytes, entityType) + + projectionMock := &ProjectionMock{ + GetContentEntityFunc: func(ctx context3.Context, weosID string) (*model.ContentEntity, error) { + return existingBlog, nil + }, + GetByKeyFunc: func(ctxt context3.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + return existingPayload, nil + }, + } + + dService1 := model.NewDomainService(newContext, mockEventRepository, projectionMock) + + t.Run("Testing with valid ID,Title and Description", func(t *testing.T) { + + //Update a blog - payload uses woesID and seq no from the created entity + updatedPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": "1", "title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext, updatedReqBytes, entityType) + + if err != nil { + t.Fatalf("unexpected error updating content type '%s'", err) + } + if updatedBlog == nil { + t.Fatal("expected blog to be returned") + } + if updatedBlog.GetString("Title") != updatedPayload["title"] { + t.Fatalf("expected blog title to be %s got %s", updatedPayload["title"], updatedBlog.GetString("Title")) + } + if updatedBlog.GetString("Description") != updatedPayload["description"] { + t.Fatalf("expected blog description to be %s got %s", updatedPayload["description"], updatedBlog.GetString("Description")) + } + if updatedBlog.GetString("Url") != updatedPayload["url"] { + t.Fatalf("expected blog url to be %s got %s", updatedPayload["url"], updatedBlog.GetString("Url")) + } + }) + + t.Run("Testing with stale sequence number", func(t *testing.T) { + + //Update a blog - payload uses woesID and seq no from the created entity + updatedPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": "3", "title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext, updatedReqBytes, entityType) + + if err == nil { + t.Fatalf("expected error updating content type '%s'", err) + } + if updatedBlog != nil { + t.Fatal("expected no blog to be returned") + } + }) + + t.Run("Testing with invalid data", func(t *testing.T) { + + //Update a blog - payload uses woesID and seq no from the created entity + updatedPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": "1", "title": nil, "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext, updatedReqBytes, entityType) + + if err == nil { + t.Fatalf("expected error updating content type '%s'", err) + } + if updatedBlog != nil { + t.Fatal("expected no blog to be returned") + } + }) +} + +func TestDomainService_UpdateCompoundPrimaryKeyID(t *testing.T) { + //load open api spec + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("../controllers/rest/fixtures/blog-pk-id.yaml") + if err != nil { + t.Fatalf("unexpected error occured '%s'", err) + } + var contentType string + var contentTypeSchema *openapi3.SchemaRef + contentType = "Blog" + contentTypeSchema = swagger.Components.Schemas[contentType] + newContext := context.Background() + newContext = context.WithValue(newContext, context2.CONTENT_TYPE, &context2.ContentType{ + Name: contentType, + Schema: contentTypeSchema.Value, + }) + + newContext1 := newContext + + //Adds primary key ID to context + newContext = context.WithValue(newContext, "id", "1") + + entityType := "Blog" + + existingPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": int64(1), "title": "blog 1", "description": "Description testing 1", "url": "www.TestBlog1.com"} + reqBytes, err := json.Marshal(existingPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + mockEventRepository := &EventRepositoryMock{ + PersistFunc: func(ctxt context.Context, entity model.AggregateInterface) error { + return nil + }, + } + + dService := model.NewDomainService(newContext, mockEventRepository, nil) + existingBlog, err := dService.Create(newContext, reqBytes, entityType) + + projectionMock := &ProjectionMock{ + GetContentEntityFunc: func(ctx context3.Context, weosID string) (*model.ContentEntity, error) { + return existingBlog, nil + }, + GetByKeyFunc: func(ctxt context3.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + return existingPayload, nil + }, + } + + t.Run("Testing with compound PK - ID", func(t *testing.T) { + dService1 := model.NewDomainService(newContext, mockEventRepository, projectionMock) + + updatedPayload := map[string]interface{}{"title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext, updatedReqBytes, entityType) + + if err != nil { + t.Fatalf("unexpected error updating content type '%s'", err) + } + if updatedBlog == nil { + t.Fatal("expected blog to be returned") + } + if updatedBlog.GetString("Title") != updatedPayload["title"] { + t.Fatalf("expected blog title to be %s got %s", updatedPayload["title"], updatedBlog.GetString("Title")) + } + if updatedBlog.GetString("Description") != updatedPayload["description"] { + t.Fatalf("expected blog description to be %s got %s", updatedPayload["description"], updatedBlog.GetString("Description")) + } + if updatedBlog.GetString("Url") != updatedPayload["url"] { + t.Fatalf("expected blog url to be %s got %s", updatedPayload["url"], updatedBlog.GetString("Url")) + } + }) + + t.Run("Testing without compound PK - ID", func(t *testing.T) { + dService1 := model.NewDomainService(newContext1, mockEventRepository, projectionMock) + + updatedPayload := map[string]interface{}{"title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext1, updatedReqBytes, entityType) + + if err == nil { + t.Fatalf("expected error updating content type '%s'", err) + } + if updatedBlog != nil { + t.Fatal("expected blog to not be returned") + } + }) +} + +func TestDomainService_UpdateCompoundPrimaryKeyGuidTitle(t *testing.T) { + //load open api spec + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("../controllers/rest/fixtures/blog-pk-guid-title.yaml") + if err != nil { + t.Fatalf("unexpected error occured '%s'", err) + } + var contentType string + var contentTypeSchema *openapi3.SchemaRef + contentType = "Blog" + contentTypeSchema = swagger.Components.Schemas[contentType] + newContext := context.Background() + newContext = context.WithValue(newContext, context2.CONTENT_TYPE, &context2.ContentType{ + Name: contentType, + Schema: contentTypeSchema.Value, + }) + + newContext1 := newContext + + //Adds primary key ID to context + newContext = context.WithValue(newContext, "guid", "1") + newContext = context.WithValue(newContext, "title", "blog 1") + + entityType := "Blog" + + existingPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": int64(1), "title": "blog 1", "description": "Description testing 1", "url": "www.TestBlog1.com"} + reqBytes, err := json.Marshal(existingPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + mockEventRepository := &EventRepositoryMock{ + PersistFunc: func(ctxt context.Context, entity model.AggregateInterface) error { + return nil + }, + } + + dService := model.NewDomainService(newContext, mockEventRepository, nil) + existingBlog, err := dService.Create(newContext, reqBytes, entityType) + + projectionMock := &ProjectionMock{ + GetContentEntityFunc: func(ctx context3.Context, weosID string) (*model.ContentEntity, error) { + return existingBlog, nil + }, + GetByKeyFunc: func(ctxt context3.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + return existingPayload, nil + }, + } + + t.Run("Testing with compound PK - GUID, Title", func(t *testing.T) { + + dService1 := model.NewDomainService(newContext, mockEventRepository, projectionMock) + + updatedPayload := map[string]interface{}{"title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext, updatedReqBytes, entityType) + + if err != nil { + t.Fatalf("unexpected error updating content type '%s'", err) + } + if updatedBlog == nil { + t.Fatal("expected blog to be returned") + } + if updatedBlog.GetString("Title") != updatedPayload["title"] { + t.Fatalf("expected blog title to be %s got %s", updatedPayload["title"], updatedBlog.GetString("Title")) + } + if updatedBlog.GetString("Description") != updatedPayload["description"] { + t.Fatalf("expected blog description to be %s got %s", updatedPayload["description"], updatedBlog.GetString("Description")) + } + if updatedBlog.GetString("Url") != updatedPayload["url"] { + t.Fatalf("expected blog url to be %s got %s", updatedPayload["url"], updatedBlog.GetString("Url")) + } + }) + + t.Run("Testing without compound PK - GUID, Title", func(t *testing.T) { + dService1 := model.NewDomainService(newContext1, mockEventRepository, projectionMock) + + updatedPayload := map[string]interface{}{"title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + updatedReqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting payload to bytes %s", err) + } + + reqBytes, err = json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + updatedBlog, err := dService1.Update(newContext1, updatedReqBytes, entityType) + + if err == nil { + t.Fatalf("expected error updating content type '%s'", err) + } + if updatedBlog != nil { + t.Fatal("expected blog to not be returned") + } + }) +} diff --git a/model/event_repository_mocks_test.go b/model/event_repository_mocks_test.go index 32343cff..ba89115b 100644 --- a/model/event_repository_mocks_test.go +++ b/model/event_repository_mocks_test.go @@ -6,6 +6,7 @@ package model_test import ( "context" "database/sql" + context2 "github.com/wepala/weos-service/context" weos "github.com/wepala/weos-service/model" "gorm.io/gorm" "net/http" @@ -496,6 +497,9 @@ var _ weos.Projection = &ProjectionMock{} // // // make and configure a mocked model.Projection // mockedProjection := &ProjectionMock{ +// GetByKeyFunc: func(ctxt context.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { +// panic("mock out the GetByKey method") +// }, // GetContentEntityFunc: func(ctx context.Context, weosID string) (*model.ContentEntity, error) { // panic("mock out the GetContentEntity method") // }, @@ -512,6 +516,9 @@ var _ weos.Projection = &ProjectionMock{} // // } type ProjectionMock struct { + // GetByKeyFunc mocks the GetByKey method. + GetByKeyFunc func(ctxt context.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) + // GetContentEntityFunc mocks the GetContentEntity method. GetContentEntityFunc func(ctx context.Context, weosID string) (*weos.ContentEntity, error) @@ -523,6 +530,15 @@ type ProjectionMock struct { // calls tracks calls to the methods. calls struct { + // GetByKey holds details about calls to the GetByKey method. + GetByKey []struct { + // Ctxt is the ctxt argument value. + Ctxt context.Context + // ContentType is the contentType argument value. + ContentType *context2.ContentType + // Identifiers is the identifiers argument value. + Identifiers map[string]interface{} + } // GetContentEntity holds details about calls to the GetContentEntity method. GetContentEntity []struct { // Ctx is the ctx argument value. @@ -539,11 +555,51 @@ type ProjectionMock struct { Ctx context.Context } } + lockGetByKey sync.RWMutex lockGetContentEntity sync.RWMutex lockGetEventHandler sync.RWMutex lockMigrate sync.RWMutex } +// GetByKey calls GetByKeyFunc. +func (mock *ProjectionMock) GetByKey(ctxt context.Context, contentType *context2.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + if mock.GetByKeyFunc == nil { + panic("ProjectionMock.GetByKeyFunc: method is nil but Projection.GetByKey was just called") + } + callInfo := struct { + Ctxt context.Context + ContentType *context2.ContentType + Identifiers map[string]interface{} + }{ + Ctxt: ctxt, + ContentType: contentType, + Identifiers: identifiers, + } + mock.lockGetByKey.Lock() + mock.calls.GetByKey = append(mock.calls.GetByKey, callInfo) + mock.lockGetByKey.Unlock() + return mock.GetByKeyFunc(ctxt, contentType, identifiers) +} + +// GetByKeyCalls gets all the calls that were made to GetByKey. +// Check the length with: +// len(mockedProjection.GetByKeyCalls()) +func (mock *ProjectionMock) GetByKeyCalls() []struct { + Ctxt context.Context + ContentType *context2.ContentType + Identifiers map[string]interface{} +} { + var calls []struct { + Ctxt context.Context + ContentType *context2.ContentType + Identifiers map[string]interface{} + } + mock.lockGetByKey.RLock() + calls = mock.calls.GetByKey + mock.lockGetByKey.RUnlock() + return calls +} + // GetContentEntity calls GetContentEntityFunc. func (mock *ProjectionMock) GetContentEntity(ctx context.Context, weosID string) (*weos.ContentEntity, error) { if mock.GetContentEntityFunc == nil { @@ -1833,3 +1889,5 @@ func (mock *ApplicationMock) TitleCalls() []struct { mock.lockTitle.RUnlock() return calls } + + diff --git a/model/interfaces.go b/model/interfaces.go index 33236364..3e9a4cac 100644 --- a/model/interfaces.go +++ b/model/interfaces.go @@ -1,7 +1,10 @@ package model //go:generate moq -out temp_mocks_test.go -pkg model_test . Projection -import "golang.org/x/net/context" +import ( + weosContext "github.com/wepala/weos-service/context" + "golang.org/x/net/context" +) type WeOSEntity interface { Entity @@ -64,4 +67,5 @@ type Projection interface { Datastore GetEventHandler() EventHandler GetContentEntity(ctx context.Context, weosID string) (*ContentEntity, error) + GetByKey(ctxt context.Context, contentType *weosContext.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) } diff --git a/model/receiver.go b/model/receiver.go index 3bd934b2..764ef44e 100644 --- a/model/receiver.go +++ b/model/receiver.go @@ -43,6 +43,20 @@ func (r *Receiver) CreateBatch(ctx context.Context, command *Command) error { return nil } +//Update is used for a single payload. It takes in the command and context which is used to dispatch and updated the specified entity. +func (r *Receiver) Update(ctx context.Context, command *Command) error { + + updatedEntity, err := r.domainService.Update(ctx, command.Payload, command.Metadata.EntityType) + if err != nil { + return err + } + err = r.service.EventRepository().Persist(ctx, updatedEntity) + if err != nil { + return err + } + return nil +} + //Initialize sets up the command handlers func Initialize(service Service) error { var payload json.RawMessage @@ -51,8 +65,15 @@ func Initialize(service Service) error { //add command handlers to the application's command dispatcher service.Dispatcher().AddSubscriber(Create(context.Background(), payload, "", ""), receiver.Create) service.Dispatcher().AddSubscriber(CreateBatch(context.Background(), payload, ""), receiver.CreateBatch) + service.Dispatcher().AddSubscriber(Update(context.Background(), payload, ""), receiver.Update) //initialize any services - receiver.domainService = NewDomainService(context.Background(), service.EventRepository()) + receiver.domainService = NewDomainService(context.Background(), service.EventRepository(), nil) + + for _, projection := range service.Projections() { + if projections, ok := projection.(Projection); ok { + receiver.domainService = NewDomainService(context.Background(), service.EventRepository(), projections) + } + } if receiver.domainService == nil { return NewError("no projection provided", nil) diff --git a/model/receiver_test.go b/model/receiver_test.go index c04b612b..7a1b6d74 100644 --- a/model/receiver_test.go +++ b/model/receiver_test.go @@ -1,6 +1,7 @@ package model_test import ( + context3 "context" "encoding/json" "github.com/getkin/kin-openapi/openapi3" weosContext "github.com/wepala/weos-service/context" @@ -124,3 +125,103 @@ func TestCreateContentType(t *testing.T) { } }) } + +func TestUpdateContentType(t *testing.T) { + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("../controllers/rest/fixtures/blog.yaml") + if err != nil { + t.Fatalf("unexpected error occured '%s'", err) + } + var contentType string + var contentTypeSchema *openapi3.SchemaRef + contentType = "Blog" + contentTypeSchema = swagger.Components.Schemas[contentType] + ctx := context.Background() + ctx = context.WithValue(ctx, weosContext.CONTENT_TYPE, &weosContext.ContentType{ + Name: contentType, + Schema: contentTypeSchema.Value, + }) + ctx = context.WithValue(ctx, weosContext.USER_ID, "123") + commandDispatcher := &model.DefaultCommandDispatcher{} + mockEventRepository := &EventRepositoryMock{ + PersistFunc: func(ctxt context.Context, entity model.AggregateInterface) error { + var event *model.Event + var ok bool + entities := entity.GetNewChanges() + if len(entities) != 2 { + t.Fatalf("expected %d event to be saved, got %d", 2, len(entities)) + } + + if event, ok = entities[0].(*model.Event); !ok { + t.Fatalf("the entity is not an event") + } + + if event.Type != "update" { + t.Errorf("expected event to be '%s', got '%s'", "update", event.Type) + } + if event.Meta.EntityType == "" { + t.Errorf("expected event to be '%s', got '%s'", "", event.Type) + } + + return nil + }, + AddSubscriberFunc: func(handler model.EventHandler) { + }, + } + + existingPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": int64(1), "title": "blog 1", "description": "Description testing 1", "url": "www.TestBlog1.com"} + existingBlog := &model.ContentEntity{ + AggregateRoot: model.AggregateRoot{ + BasicEntity: model.BasicEntity{ + ID: "dsafdsdfdsf", + }, + SequenceNo: int64(0), + }, + Property: existingPayload, + } + event := model.NewEntityEvent("update", existingBlog, existingBlog.ID, existingPayload) + existingBlog.NewChange(event) + + projectionMock := &ProjectionMock{ + GetContentEntityFunc: func(ctx context3.Context, weosID string) (*model.ContentEntity, error) { + return existingBlog, nil + }, + GetByKeyFunc: func(ctxt context3.Context, contentType *weosContext.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + return existingPayload, nil + }, + } + + application := &ApplicationMock{ + DispatcherFunc: func() model.Dispatcher { + return commandDispatcher + }, + EventRepositoryFunc: func() model.EventRepository { + return mockEventRepository + }, + ProjectionsFunc: func() []model.Projection { + return []model.Projection{projectionMock} + }, + } + + err1 := model.Initialize(application) + if err1 != nil { + t.Fatalf("unexpected error setting up model '%s'", err1) + } + + t.Run("Testing basic update entity", func(t *testing.T) { + updatedPayload := map[string]interface{}{"weos_id": "dsafdsdfdsf", "sequence_no": "1", "title": "Update Blog", "description": "Update Description", "url": "www.Updated!.com"} + entityType := "Blog" + reqBytes, err := json.Marshal(updatedPayload) + if err != nil { + t.Fatalf("error converting content type to bytes %s", err) + } + + err1 := commandDispatcher.Dispatch(ctx, model.Update(ctx, reqBytes, entityType)) + if err1 != nil { + t.Fatalf("unexpected error dispatching command '%s'", err1) + } + + if len(mockEventRepository.PersistCalls()) != 1 { + t.Fatalf("expected change events to be persisted '%d' got persisted '%d' times", 1, len(mockEventRepository.PersistCalls())) + } + }) +} diff --git a/model/utils.go b/model/utils.go index 5f6a5193..57722634 100644 --- a/model/utils.go +++ b/model/utils.go @@ -47,3 +47,20 @@ func GetIDfromPayload(payload []byte) (string, error) { return weosID, nil } + +//GetSeqfromPayload: This returns the sequence number from payload +func GetSeqfromPayload(payload []byte) (string, error) { + var tempPayload map[string]interface{} + err := json.Unmarshal(payload, &tempPayload) + if err != nil { + return "", err + } + + if tempPayload["sequence_no"] == nil { + tempPayload["sequence_no"] = "" + } + + seqNo := tempPayload["sequence_no"].(string) + + return seqNo, nil +} diff --git a/projections/gorm.go b/projections/gorm.go index 35f8a482..37dd5171 100644 --- a/projections/gorm.go +++ b/projections/gorm.go @@ -2,7 +2,7 @@ package projections import ( "encoding/json" - + "fmt" "strings" ds "github.com/ompluscator/dynamic-struct" @@ -10,6 +10,7 @@ import ( weos "github.com/wepala/weos-service/model" "golang.org/x/net/context" "gorm.io/gorm" + "gorm.io/gorm/clause" ) //GORMProjection interface struct @@ -20,6 +21,74 @@ type GORMProjection struct { Schema map[string]interface{} } +func (p *GORMProjection) GetByKey(ctxt context.Context, contentType *weosContext.ContentType, identifiers map[string]interface{}) (map[string]interface{}, error) { + if scheme, ok := p.Schema[strings.Title(contentType.Name)]; ok { + //pulling the primary keys from the schema in order to match with the keys given for searching + pks, _ := json.Marshal(contentType.Schema.Extensions["x-identifier"]) + primaryKeys := []string{} + json.Unmarshal(pks, &primaryKeys) + + if len(primaryKeys) == 0 { + primaryKeys = append(primaryKeys, "id") + } + + if len(primaryKeys) != len(identifiers) { + return nil, fmt.Errorf("%d keys provided for %s but there should be %d keys", len(identifiers), contentType.Name, len(primaryKeys)) + } + + for _, k := range primaryKeys { + found := false + for i, _ := range identifiers { + if k == i { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("no value for %s %s found", contentType.Name, k) + } + } + + var result *gorm.DB + + result = p.db.Table(contentType.Name).Scopes(ContentQuery()).First(scheme, identifiers) + + if result.Error != nil { + return nil, result.Error + } + data, err := json.Marshal(scheme) + if err != nil { + return nil, err + } + val := map[string]interface{}{} + json.Unmarshal(data, &val) + return val, nil + } else { + return nil, fmt.Errorf("no content type '%s' exists", contentType.Name) + } +} + +func (p *GORMProjection) GetByEntityID(ctxt context.Context, contentType weosContext.ContentType, id string) (map[string]interface{}, error) { + if scheme, ok := p.Schema[strings.Title(contentType.Name)]; ok { + var result *gorm.DB + + result = p.db.Table(contentType.Name).Scopes(ContentQuery()).Where("weos_id = ?", id).Take(scheme) + + if result.Error != nil { + return nil, result.Error + } + data, err := json.Marshal(scheme) + if err != nil { + return nil, err + } + val := map[string]interface{}{} + json.Unmarshal(data, &val) + return val, nil + } else { + return nil, fmt.Errorf("no content type '%s' exists", contentType.Name) + } +} + //Persist save entity information in database func (p *GORMProjection) Persist(entities []weos.Entity) error { return nil @@ -30,15 +99,6 @@ func (p *GORMProjection) Remove(entities []weos.Entity) error { return nil } -func (p *GORMProjection) GetByID(ctxt context.Context, contentType weosContext.ContentType, identifier []interface{}) (interface{}, error) { - - return nil, nil -} - -func (p *GORMProjection) GetByEntityID(ctxt context.Context, contentType weosContext.ContentType, id string) (interface{}, error) { - return nil, nil -} - func (p *GORMProjection) Migrate(ctx context.Context) error { //we may need to reorder the creation so that tables don't reference things that don't exist as yet. @@ -148,6 +208,11 @@ func (p *GORMProjection) GetContentEntity(ctx context.Context, weosID string) (* return newEntity, nil } +//query modifier for making queries to the database +type QueryModifier func() func(db *gorm.DB) *gorm.DB + +var ContentQuery QueryModifier + //NewProjection creates an instance of the projection func NewProjection(ctx context.Context, application weos.Service, schemas map[string]interface{}) (*GORMProjection, error) { @@ -157,5 +222,17 @@ func NewProjection(ctx context.Context, application weos.Service, schemas map[st Schema: schemas, } application.AddProjection(projection) + + ContentQuery = func() func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if projection.db.Dialector.Name() == "sqlite" { + //gorm sqlite generates the query incorrectly if there are composite keys when preloading + //https://github.com/go-gorm/gorm/issues/3585 + return db + } else { + return db.Preload(clause.Associations, func(tx *gorm.DB) *gorm.DB { return tx.Omit("weos_id, sequence_no") }) + } + } + } return projection, nil } diff --git a/projections/projections.go b/projections/projections.go index 79e8ea99..9f1b61fa 100644 --- a/projections/projections.go +++ b/projections/projections.go @@ -10,7 +10,7 @@ type Projection interface { } type DefaultProjection struct { - WEOSID string `json:"weos_id" gorm:"unique"` - SequenceNo int64 `json:"sequence_no"` + WEOSID string `json:"weos_id,omitempty" gorm:"unique"` + SequenceNo int64 `json:"sequence_no,omitempty"` Table string `json:"table_alias"` } diff --git a/projections/projections_test.go b/projections/projections_test.go index 219e6765..35c9c959 100644 --- a/projections/projections_test.go +++ b/projections/projections_test.go @@ -1078,6 +1078,296 @@ components: }) } +func TestProjections_GetContentTypeByEntityID(t *testing.T) { + openAPI := `openapi: 3.0.3 +info: + title: Blog + description: Blog example + version: 1.0.0 +servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 +components: + schemas: + Post: + type: object + properties: + title: + type: string + description: blog title + description: + type: string + Blog: + type: object + properties: + title: + type: string + description: blog title + description: + type: string + posts: + type: array + items: + $ref: "#/components/schemas/Post" +` + + loader := openapi3.NewSwaggerLoader() + swagger, err := loader.LoadSwaggerFromData([]byte(openAPI)) + if err != nil { + t.Fatal(err) + } + + schemes := rest.CreateSchema(context.Background(), echo.New(), swagger) + p, err := projections.NewProjection(context.Background(), app, schemes) + if err != nil { + t.Fatal(err) + } + + err = p.Migrate(context.Background()) + if err != nil { + t.Fatal(err) + } + + gormDB := app.DB() + if !gormDB.Migrator().HasTable("Blog") { + t.Errorf("expected to get a table 'Blog'") + } + + if !gormDB.Migrator().HasTable("Post") { + t.Errorf("expected to get a table 'Post'") + } + + if !gormDB.Migrator().HasTable("blog_posts") { + t.Errorf("expected to get a table 'blog_posts'") + } + + columns, _ := gormDB.Migrator().ColumnTypes("blog_posts") + + found := false + found1 := false + for _, c := range columns { + if c.Name() == "id" { + found = true + } + if c.Name() == "post_id" { + found1 = true + } + } + + if !found1 || !found { + t.Fatal("not all fields found") + } + gormDB.Table("Post").Create(map[string]interface{}{"weos_id": "1234", "sequence_no": 1, "title": "punches"}) + gormDB.Table("Blog").Create(map[string]interface{}{"weos_id": "5678", "sequence_no": 1, "title": "hugs"}) + result := gormDB.Table("blog_posts").Create(map[string]interface{}{ + "id": 1, + "post_id": 1, + }) + if result.Error != nil { + t.Errorf("expected to create a post with relationship, got err '%s'", result.Error) + } + + r, err := p.GetByEntityID(context.Background(), weosContext.ContentType{Name: "Blog"}, "5678") + if err != nil { + t.Fatalf("error querying '%s' '%s'", "Blog", err) + } + if r["title"] != "hugs" { + t.Errorf("expected the blog title to be %s got %v", "hugs", r["titles"]) + } + + if *driver != "sqlite3" { + posts := r["posts"].([]interface{}) + if len(posts) != 1 { + t.Errorf("expected to get %d posts, got %d", 1, len(posts)) + } + + pp := posts[0].(map[string]interface{}) + if pp["title"] != "punches" { + t.Errorf("expected the post title to be %s got %v", "punches", pp["title"]) + } + + if id, ok := pp["weos_id"]; ok { + if id != "" { + t.Errorf("there should be no weos_id value") + } + } + + if no, ok := pp["sequence_no"]; ok { + if no != 0 { + t.Errorf("there should be no sequence number value") + } + } + } + + err = gormDB.Migrator().DropTable("Blog") + if err != nil { + t.Errorf("error removing table '%s' '%s'", "Blog", err) + } + err = gormDB.Migrator().DropTable("Post") + if err != nil { + t.Errorf("error removing table '%s' '%s'", "Post", err) + } + err = gormDB.Migrator().DropTable("blog_posts") + if err != nil { + t.Errorf("error removing table '%s' '%s'", "blog_posts", err) + } +} + +func TestProjections_GetContentTypeByKeys(t *testing.T) { + openAPI := `openapi: 3.0.3 +info: + title: Blog + description: Blog example + version: 1.0.0 +servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 +components: + schemas: + Post: + type: object + properties: + title: + type: string + description: blog title + description: + type: string + Blog: + type: object + properties: + title: + type: string + description: blog title + author_id: + type: string + description: + type: string + posts: + type: array + items: + $ref: "#/components/schemas/Post" + x-identifier: + - title + - author_id +` + + loader := openapi3.NewSwaggerLoader() + swagger, err := loader.LoadSwaggerFromData([]byte(openAPI)) + if err != nil { + t.Fatal(err) + } + + schemes := rest.CreateSchema(context.Background(), echo.New(), swagger) + p, err := projections.NewProjection(context.Background(), app, schemes) + if err != nil { + t.Fatal(err) + } + + err = p.Migrate(context.Background()) + if err != nil { + t.Fatal(err) + } + + gormDB := app.DB() + if !gormDB.Migrator().HasTable("Blog") { + t.Errorf("expected to get a table 'Blog'") + } + + if !gormDB.Migrator().HasTable("Post") { + t.Errorf("expected to get a table 'Post'") + } + + if !gormDB.Migrator().HasTable("blog_posts") { + t.Errorf("expected to get a table 'blog_posts'") + } + + columns, _ := gormDB.Migrator().ColumnTypes("blog_posts") + + found := false + found1 := false + found2 := false + for _, c := range columns { + if c.Name() == "id" { + found = true + } + if c.Name() == "title" { + found1 = true + } + if c.Name() == "author_id" { + found2 = true + } + } + + if !found1 || !found || !found2 { + t.Fatal("not all fields found") + } + gormDB.Table("Post").Create(map[string]interface{}{"weos_id": "1234", "sequence_no": 1, "title": "punches"}) + gormDB.Table("Blog").Create(map[string]interface{}{"weos_id": "5678", "sequence_no": 1, "title": "hugs", "author_id": "kidding"}) + gormDB.Table("Blog").Create(map[string]interface{}{"weos_id": "9101", "sequence_no": 1, "title": "hugs 2 - the reckoning", "author_id": "kidding"}) + result := gormDB.Table("blog_posts").Create(map[string]interface{}{ + "author_id": "kidding", + "title": "hugs", + "id": 1, + }) + if result.Error != nil { + t.Errorf("expected to create a post with relationship, got err '%s'", result.Error) + } + + blogRef := swagger.Components.Schemas["Blog"] + r, err := p.GetByKey(context.Background(), &weosContext.ContentType{Name: "Blog", Schema: blogRef.Value}, map[string]interface{}{ + "author_id": "kidding", + "title": "hugs", + }) + if err != nil { + t.Fatalf("error querying '%s' '%s'", "Blog", err) + } + + if r["title"] != "hugs" { + t.Errorf("expected the blog title to be %s got %v", "hugs", r["titles"]) + } + + if *driver != "sqlite3" { + posts, ok := r["posts"].([]interface{}) + if !ok { + t.Fatal("expected to get a posts array") + } + if len(posts) != 1 { + t.Errorf("expected to get %d posts, got %d", 1, len(posts)) + } + + pp := posts[0].(map[string]interface{}) + if pp["title"] != "punches" { + t.Errorf("expected the post title to be %s got %v", "punches", pp["title"]) + } + + if id, ok := pp["weos_id"]; ok { + if id != "" { + t.Errorf("there should be no weos_id value") + } + } + + if no, ok := pp["sequence_no"]; ok { + if no != 0 { + t.Errorf("there should be no sequence number value") + } + } + } + err = gormDB.Migrator().DropTable("Blog") + if err != nil { + t.Errorf("error removing table '%s' '%s'", "Blog", err) + } + err = gormDB.Migrator().DropTable("Post") + if err != nil { + t.Errorf("error removing table '%s' '%s'", "Post", err) + } + err = gormDB.Migrator().DropTable("blog_posts") + if err != nil { + t.Errorf("error removing table '%s' '%s'", "blog_posts", err) + } +} + func TestProjections_GormOperations(t *testing.T) { t.Run("Basic Create using schema", func(t *testing.T) { openAPI := `openapi: 3.0.3