diff --git a/api.yaml b/api.yaml index 990c4e7b..52fc2bce 100644 --- a/api.yaml +++ b/api.yaml @@ -94,9 +94,14 @@ components: lastUpdated: type: string format: date-time + x-update: + - Add Blog + - Update Blog created: type: string format: date-time + x-update: + - Add Blog required: - title - url diff --git a/context/context.go b/context/context.go index 0ec830f3..01124410 100644 --- a/context/context.go +++ b/context/context.go @@ -14,6 +14,7 @@ const HeaderXLogLevel = "X-LOG-LEVEL" //add more keys here if needed const ACCOUNT_ID ContextKey = "ACCOUNT_ID" +const OPERATION_ID = "OPERATION_ID" const USER_ID ContextKey = "USER_ID" const LOG_LEVEL ContextKey = "LOG_LEVEL" const REQUEST_ID ContextKey = "REQUEST_ID" diff --git a/controllers/rest/api.go b/controllers/rest/api.go index 5bacddea..d4c477df 100644 --- a/controllers/rest/api.go +++ b/controllers/rest/api.go @@ -416,6 +416,37 @@ func (p *RESTAPI) Initialize(ctxt context.Context) error { } } + //this ranges over the paths and pulls out the operationIDs into an array + opIDs := []string{} + idFound := false + for _, pathData := range p.Swagger.Paths { + for _, op := range pathData.Operations() { + if op.OperationID != "" { + opIDs = append(opIDs, op.OperationID) + } + } + } + + //this ranges over the properties, pulls the x-update and them compares it against the valid operation ids in the yaml + for _, scheme := range p.Swagger.Components.Schemas { + for _, prop := range scheme.Value.Properties { + xUpdate := []string{} + xUpdateBytes, _ := json.Marshal(prop.Value.Extensions["x-update"]) + json.Unmarshal(xUpdateBytes, &xUpdate) + for _, r := range xUpdate { + idFound = false + for _, id := range opIDs { + if r == id { + idFound = true + } + } + if !idFound { + return fmt.Errorf("provided x-update operation id: %s is invalid", r) + } + } + } + } + //get the database schema schemas = CreateSchema(ctxt, p.EchoInstance(), p.Swagger) p.Schemas = schemas diff --git a/controllers/rest/api_test.go b/controllers/rest/api_test.go index 15369a90..5ec3e17a 100644 --- a/controllers/rest/api_test.go +++ b/controllers/rest/api_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "github.com/labstack/echo/v4" + "io/ioutil" "net/http" "net/http/httptest" "os" @@ -747,3 +748,45 @@ components: }) os.Remove("test.db") } + +func TestRESTAPI_Integration_AutomaticallyUpdateDateTime(t *testing.T) { + tapi, err := api.New("./fixtures/blog.yaml") + if err != nil { + t.Fatalf("un expected error loading spec '%s'", err) + } + err = tapi.Initialize(nil) + if err != nil { + t.Fatalf("un expected error loading spec '%s'", err) + } + e := tapi.EchoInstance() + t.Run("automatically updating create", func(t *testing.T) { + mockBlog := map[string]interface{}{"title": "Test Blog", "url": "www.testBlog.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) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + e.ServeHTTP(resp, req) + if resp.Result().StatusCode != http.StatusCreated { + t.Errorf("expected the response code to be %d, got %d", http.StatusCreated, resp.Result().StatusCode) + } + defer resp.Result().Body.Close() + tResults, err := ioutil.ReadAll(resp.Result().Body) + if err != nil { + t.Errorf("unexpect error: %s", err) + } + results := map[string]interface{}{} + err = json.Unmarshal(tResults, &results) + if err != nil { + t.Errorf("unexpect error: %s", err) + } + if results["created"] == nil || results["lastUpdated"] == nil { + t.Errorf("unexpect error expected to find created and lastUpdated") + } + + }) + +} \ No newline at end of file diff --git a/controllers/rest/fixtures/blog.yaml b/controllers/rest/fixtures/blog.yaml index f0cc8a3d..63915355 100644 --- a/controllers/rest/fixtures/blog.yaml +++ b/controllers/rest/fixtures/blog.yaml @@ -91,9 +91,14 @@ components: lastUpdated: type: string format: date-time + x-update: + - Add Blog + - Update Blog created: type: string format: date-time + x-update: + - Add Blog required: - title - url diff --git a/controllers/rest/middleware_context.go b/controllers/rest/middleware_context.go index 42ff1e82..32f33ef7 100644 --- a/controllers/rest/middleware_context.go +++ b/controllers/rest/middleware_context.go @@ -54,6 +54,9 @@ func Context(api *RESTAPI, projection projections.Projection, commandDispatcher cc, err = parseResponses(c, cc, operation) + //add OperationID to context + cc = context.WithValue(cc, weosContext.OPERATION_ID, operation.OperationID) + //parse request body based on content type var payload []byte ct := c.Request().Header.Get("Content-Type") diff --git a/controllers/rest/middleware_context_test.go b/controllers/rest/middleware_context_test.go index 616afc7b..45f7bc12 100644 --- a/controllers/rest/middleware_context_test.go +++ b/controllers/rest/middleware_context_test.go @@ -561,4 +561,26 @@ func TestContext(t *testing.T) { e.GET("/blogs/:id", handler) e.ServeHTTP(resp, req) }) + + t.Run("add operationId to context", func(t *testing.T) { + path := swagger.Paths.Find("/blogs") + mw := rest.Context(restApi, nil, nil, nil, entityFactory, path, path.Get) + handler := mw(func(ctxt echo.Context) error { + //check that certain parameters are in the context + cc := ctxt.Request().Context() + value := cc.Value(context.OPERATION_ID) + if value == nil { + t.Fatalf("expected the operation id to have a value") + } + if value.(string) != "Get Blogs" { + t.Fatalf("expected the operation id to be Get Blogs") + } + return nil + }) + e := echo.New() + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/blogs", nil) + e.GET("/blogs", handler) + e.ServeHTTP(resp, req) + }) } diff --git a/end2end_test.go b/end2end_test.go index d14393cb..e5b6b0e0 100644 --- a/end2end_test.go +++ b/end2end_test.go @@ -760,7 +760,11 @@ func theServiceIsRunning() error { }) err := API.Initialize(scenarioContext) if err != nil { - return err + if strings.Contains(err.Error(), "provided x-update operation id") { + errs = err + } else { + return err + } } proj, err := API.GetProjection("Default") if err == nil { @@ -879,7 +883,7 @@ func theIsUpdated(contentType string, details *godog.Table) error { } } - contentEntity := map[string]interface{}{} + contentEntity = map[string]interface{}{} var result *gorm.DB //ETag would help with this for key, value := range compare { @@ -1758,6 +1762,35 @@ func theIdShouldBeA(arg1, format string) error { return nil } +func anErrorIsReturned() error { + if !strings.Contains(errs.Error(), "provided x-update operation id") { + return fmt.Errorf("expected the error to contain: %s, got %s", "provided x-update operation id", errs.Error()) + } + return nil +} + +func theFieldShouldHaveTodaysDate(field string) error { + + timeNow := time.Now() + todaysDate := timeNow.Format("2006-01-02") + + switch dbconfig.Driver { + case "postgres", "mysql": + date := contentEntity[field].(time.Time).Format("2006-01-02") + if !strings.Contains(date, todaysDate) { + return fmt.Errorf("expected the %s date: %s to contain the current date: %s ", field, date, todaysDate) + } + + case "sqlite3": + date := contentEntity[field].(string) + if !strings.Contains(date, todaysDate) { + return fmt.Errorf("expected the %s date: %s to contain the current date: %s ", field, date, todaysDate) + } + } + + return nil +} + func InitializeScenario(ctx *godog.ScenarioContext) { ctx.Before(reset) ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { @@ -1859,7 +1892,8 @@ func InitializeScenario(ctx *godog.ScenarioContext) { ctx.Step(`^"([^"]*)" set the default event store as "([^"]*)"$`, setTheDefaultEventStoreAs) ctx.Step(`^the projection "([^"]*)" is not called$`, theProjectionIsNotCalled) ctx.Step(`^the "([^"]*)" id should be a "([^"]*)"$`, theIdShouldBeA) - + ctx.Step(`^an error is returned$`, anErrorIsReturned) + ctx.Step(`^the "([^"]*)" field should have today\'s date$`, theFieldShouldHaveTodaysDate) } func TestBDD(t *testing.T) { diff --git a/features/define-content-type-oas.feature b/features/define-content-type-oas.feature index 92f3e59f..a38d8a82 100644 --- a/features/define-content-type-oas.feature +++ b/features/define-content-type-oas.feature @@ -507,3 +507,465 @@ Feature: Create Content Types """ When the "OpenAPI 3.0" specification is parsed Then an error should be returned + + @WEOS-1342 + Scenario: Create field on a schema that should only be updated on create + + Developers can indicate in the schema the list of operations that would trigger an update on a date field using the + x-update extension. The values are the operationIds of the operations that should trigger the update in value. By + updating + + Given the specification is + """ + openapi: 3.0.3 + info: + title: Blog Aggregator Rest API + version: 0.1.0 + description: REST API for interacting with the Blog Aggregator + servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 + x-weos-config: + database: + database: "%s" + driver: "%s" + host: "%s" + password: "%s" + username: "%s" + port: %d + rest: + middleware: + - RequestID + - Recover + - ZapLogger + components: + schemas: + Blog: + type: object + properties: + id: + type: string + title: + type: string + description: blog title + description: + type: string + created: + type: string + format: date-time + x-update: + - Add Blog + required: + - title + x-identifier: + - id + paths: + /: + get: + operationId: Homepage + responses: + 200: + description: Application Homepage + /blog: + post: + operationId: Add 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" + application/xml: + schema: + $ref: "#/components/schemas/Blog" + responses: + 201: + description: Add Blog to Aggregator + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + 400: + description: Invalid blog submitted + /blogs/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + - in: query + name: sequence_no + schema: + type: string + 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 + - in: header + name: If-Match + 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 + """ + And blogs in the api + | id | weos_id | sequence_no | title | description | + | 1234 | 986888285 | 1 | Blog 1 | Some Blog | + | 4567 | 5uhq85nal | 1 | Blog 2 | Some Blog 2 | + And the service is running + And "Sojourner" is on the "Blog" create screen + And "Sojourner" enters "3" in the "id" field + And "Sojourner" enters "Some Blog" in the "title" field + And "Sojourner" enters "Some Description" in the "description" field + When the "Blog" is submitted + Then the "Blog" is created + | title | description | + | Some Blog | Some Description | + And the "created" field should have today's date + + @WEOS-1342 + Scenario: Updated field on a schema that should updated when the entity is updated + + Given the specification is + """ + openapi: 3.0.3 + info: + title: Blog Aggregator Rest API + version: 0.1.0 + description: REST API for interacting with the Blog Aggregator + servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 + x-weos-config: + database: + database: "%s" + driver: "%s" + host: "%s" + password: "%s" + username: "%s" + port: %d + rest: + middleware: + - RequestID + - Recover + - ZapLogger + components: + schemas: + Blog: + type: object + properties: + id: + type: string + title: + type: string + description: blog title + description: + type: string + updated: + type: string + format: date-time + x-update: + - Update Blog + required: + - title + x-identifier: + - id + paths: + /: + get: + operationId: Homepage + responses: + 200: + description: Application Homepage + /blog: + post: + operationId: Add 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" + application/xml: + schema: + $ref: "#/components/schemas/Blog" + responses: + 201: + description: Add Blog to Aggregator + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + 400: + description: Invalid blog submitted + /blogs/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + - in: query + name: sequence_no + schema: + type: string + 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 + - in: header + name: If-Match + 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 + """ + And blogs in the api + | id | weos_id | sequence_no | title | description | + | 1234 | 986888285 | 1 | Blog 1 | Some Blog | + | 4567 | 5uhq85nal | 1 | Blog 2 | Some Blog 2 | + And the service is running + And "Sojourner" is on the "Blog" edit screen with id "1234" + And "Sojourner" enters "Some New Title" in the "title" field + And "Sojourner" enters "Some Description" in the "description" field + When the "Blog" is submitted + Then a 200 response should be returned + And the "Blog" is updated + | title | description | + | Some New Title | Some Description | + And the "updated" field should have today's date + + @WEOS-1342 + Scenario: Reference invalid operation id + + If an invalid operation id is referenced then return an error to the developer when starting up the API + + Given the specification is + """ + openapi: 3.0.3 + info: + title: Blog Aggregator Rest API + version: 0.1.0 + description: REST API for interacting with the Blog Aggregator + servers: + - url: https://prod1.weos.sh/blog/dev + description: WeOS Dev + - url: https://prod1.weos.sh/blog/v1 + x-weos-config: + database: + database: "%s" + driver: "%s" + host: "%s" + password: "%s" + username: "%s" + port: %d + rest: + middleware: + - RequestID + - Recover + - ZapLogger + components: + schemas: + Blog: + type: object + properties: + id: + type: string + title: + type: string + description: blog title + description: + type: string + updated: + type: string + format: date-time + x-update: + - asdfasdf asdf asfd + required: + - title + x-identifier: + - id + paths: + /: + get: + operationId: Homepage + responses: + 200: + description: Application Homepage + /blog: + post: + operationId: Add 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" + application/xml: + schema: + $ref: "#/components/schemas/Blog" + responses: + 201: + description: Add Blog to Aggregator + content: + application/json: + schema: + $ref: "#/components/schemas/Blog" + 400: + description: Invalid blog submitted + /blogs/{id}: + get: + parameters: + - in: path + name: id + schema: + type: string + required: true + description: blog id + - in: query + name: sequence_no + schema: + type: string + 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 + - in: header + name: If-Match + 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 + """ + When the service is running + Then an error is returned \ No newline at end of file diff --git a/model/content_entity.go b/model/content_entity.go index f298b35f..bba66cba 100644 --- a/model/content_entity.go +++ b/model/content_entity.go @@ -442,7 +442,16 @@ func (w *ContentEntity) FromSchemaAndBuilder(ctx context.Context, ref *openapi3. } func (w *ContentEntity) Init(ctx context.Context, payload json.RawMessage) (*ContentEntity, error) { - err := w.SetValueFromPayload(ctx, payload) + var err error + //update default time update values based on routes + operation, ok := ctx.Value(weosContext.OPERATION_ID).(string) + if ok { + payload, err = w.UpdateTime(operation, payload) + if err != nil { + return nil, err + } + } + err = w.SetValueFromPayload(ctx, payload) if err != nil { return nil, err } @@ -823,3 +832,27 @@ func (w *ContentEntity) GenerateID(payload []byte) error { return json.Unmarshal(generatedIdentifier, w) } + +//UpdateTime updates auto update time values on the payload +func (w *ContentEntity) UpdateTime(operationID string, data []byte) ([]byte, error) { + payload := map[string]interface{}{} + json.Unmarshal(data, &payload) + for key, p := range w.Schema.Properties { + routes := []string{} + routeBytes, _ := json.Marshal(p.Value.Extensions["x-update"]) + json.Unmarshal(routeBytes, &routes) + for _, r := range routes { + if r == operationID { + if p.Value.Format == "date-time" { + payload[key] = time.Now() + } + } + } + } + newPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + return newPayload, nil +} diff --git a/model/content_entity_test.go b/model/content_entity_test.go index 5332adf2..307d0166 100644 --- a/model/content_entity_test.go +++ b/model/content_entity_test.go @@ -958,3 +958,36 @@ func TestContentEntity_AutoGeneratedID(t *testing.T) { } }) } + +func TestContentEntity_UpdateTime(t *testing.T) { + //load open api spec + api, err := rest.New("../controllers/rest/fixtures/blog.yaml") + schemas := rest.CreateSchema(context.TODO(), api.EchoInstance(), api.Swagger) + contentType := "Blog" + entityFactory := new(model.DefaultEntityFactory).FromSchemaAndBuilder(contentType, api.Swagger.Components.Schemas[contentType].Value, schemas[contentType]) + if err != nil { + t.Fatalf("error setting up entity factory") + } + entity, err := entityFactory.NewEntity(context.TODO()) + if err != nil { + t.Fatalf("error generating entity '%s'", err) + } + + mapPayload := map[string]interface{}{"title": "update time", "description": "new time", "url": "www.MyBlog.com"} + newPayload, errr := json.Marshal(mapPayload) + if errr != nil { + t.Fatalf("error marshalling Payload '%s'", err) + } + + updatedTimePayload, errrr := entity.UpdateTime("Update Blog", newPayload) + if errrr != nil { + t.Fatalf("error updating time payload '%s'", err) + } + + tempPayload := map[string]interface{}{} + json.Unmarshal(updatedTimePayload, &tempPayload) + + if tempPayload["lastUpdated"] == "" { + t.Fatalf("expected the lastupdated field to not be blank") + } +} diff --git a/model/domain_service.go b/model/domain_service.go index 68496688..065bebeb 100644 --- a/model/domain_service.go +++ b/model/domain_service.go @@ -166,7 +166,7 @@ func (s *DomainService) Update(ctx context.Context, payload json.RawMessage, ent seqNo = seq } - existingEntity, err := s.GetContentEntity(ctx, entityFactory, weosID) + existingEntity, err = s.GetContentEntity(ctx, entityFactory, weosID) if err != nil { return nil, NewDomainError("invalid: unexpected error fetching existing entity", entityType, weosID, err) } @@ -199,6 +199,15 @@ func (s *DomainService) Update(ctx context.Context, payload json.RawMessage, ent } } + //update default time update values based on routes + operation, ok := ctx.Value(weosContext.OPERATION_ID).(string) + if ok { + newPayload, err = existingEntity.UpdateTime(operation, newPayload) + } + if err != nil { + return nil, err + } + updatedEntity, err = existingEntity.Update(ctx, newPayload) if err != nil { return nil, err @@ -242,6 +251,15 @@ func (s *DomainService) Update(ctx context.Context, payload json.RawMessage, ent return nil, err } + //update default time update values based on routes + operation, ok := ctx.Value(weosContext.OPERATION_ID).(string) + if ok { + newPayload, err = existingEntity.UpdateTime(operation, newPayload) + if err != nil { + return nil, err + } + } + updatedEntity, err = existingEntity.Update(ctx, newPayload) if err != nil { s.logger.Errorf("error updating entity", err) @@ -321,6 +339,15 @@ func (s *DomainService) Delete(ctx context.Context, entityID string, entityType return nil, err } + //update default time update values based on routes + operation, ok := ctx.Value(weosContext.OPERATION_ID).(string) + if ok { + existingEntityPayload, err = existingEntity.UpdateTime(operation, existingEntityPayload) + if err != nil { + return nil, err + } + } + deletedEntity, err = existingEntity.Delete(existingEntityPayload) if err != nil { return nil, err @@ -347,6 +374,15 @@ func (s *DomainService) Delete(ctx context.Context, entityID string, entityType return nil, err } + //update default time update values based on routes + operation, ok := ctx.Value(weosContext.OPERATION_ID).(string) + if ok { + data, err = existingEntity.UpdateTime(operation, data) + if err != nil { + return nil, err + } + } + deletedEntity, err = existingEntity.Delete(data) if err != nil { return nil, err