Skip to content

Commit

Permalink
feature: #279 Added generic route for writing resources
Browse files Browse the repository at this point in the history
* Updated write controller to NOT use commands
* Added Initialize method to resource repository to instantiate resource from projection or create a new one
* Made it so that the ResourceRepository stores the reference to projections
* Added GORM projections that serves as the default projection that stores resources as well as the event store which stores the events.
* Added Routes for resources that are linked to the DefaultWriteController
  • Loading branch information
akeemphilbert committed Mar 31, 2024
1 parent 56454cf commit c61640a
Show file tree
Hide file tree
Showing 18 changed files with 608 additions and 210 deletions.
1 change: 1 addition & 0 deletions v2/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,5 @@ require (
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/datatypes v1.2.0 // indirect
)
5 changes: 5 additions & 0 deletions v2/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
Expand Down Expand Up @@ -96,6 +97,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
Expand Down Expand Up @@ -218,6 +220,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand All @@ -226,6 +229,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
Expand Down
4 changes: 2 additions & 2 deletions v2/rest/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ var API = fx.Module("rest",
NewEcho,
NewZap,
NewGORM,
NewGORMResourceRepository,
NewCommandDispatcher,
NewGORMEventStore,
NewResourceRepository,
NewGORMProjection,
),
fx.Invoke(RouteInitializer, registerHooks),
)
3 changes: 1 addition & 2 deletions v2/rest/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ type CommandDispatcherParams struct {
fx.In
CommandConfigs []CommandConfig `group:"commandHandlers"`
Logger Log
Repository Repository
}

type CommandDispatcherResult struct {
Expand All @@ -44,7 +43,7 @@ type DefaultCommandDispatcher struct {
dispatch sync.Mutex
}

func (e *DefaultCommandDispatcher) Dispatch(ctx context.Context, command *Command, repository Repository, logger Log) (interface{}, error) {
func (e *DefaultCommandDispatcher) Dispatch(ctx context.Context, command *Command, repository *ResourceRepository, logger Log) (interface{}, error) {
var wg sync.WaitGroup
var err error
var result interface{}
Expand Down
11 changes: 4 additions & 7 deletions v2/rest/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,13 @@ type WeOSConfigResult struct {
func WeOSConfig(p WeOSConfigParams) (WeOSConfigResult, error) {
if p.Config != nil {
var config *APIConfig
if _, ok := p.Config.Extensions[WeOSConfigExtension]; ok {
data, err := p.Config.Extensions[WeOSConfigExtension].(json.RawMessage).MarshalJSON()
if data, ok := p.Config.Extensions[WeOSConfigExtension]; ok {
dataBytes, err := json.Marshal(data)
if err != nil {
p.Logger.Errorf("error encountered marshalling config '%s'", err)
return WeOSConfigResult{}, err
}
err = json.Unmarshal(data, &config)
if err != nil {
return WeOSConfigResult{}, err
}

err = json.Unmarshal(dataBytes, &config)
return WeOSConfigResult{
Config: config,
}, nil
Expand Down
70 changes: 48 additions & 22 deletions v2/rest/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,46 @@ import (
)

// DefaultWriteController handles the write operations (create, update, delete)
func DefaultWriteController(logger Log, commandDispatcher CommandDispatcher, resourceRepository Repository, api *openapi3.T, pathMap map[string]*openapi3.PathItem, operation map[string]*openapi3.Operation) echo.HandlerFunc {
func DefaultWriteController(logger Log, commandDispatcher CommandDispatcher, resourceRepository *ResourceRepository, api *openapi3.T, pathMap map[string]*openapi3.PathItem, operation map[string]*openapi3.Operation) echo.HandlerFunc {

var err error

return func(ctxt echo.Context) error {
var sequenceNo string
var seq int

//getting etag from context
etag := ctxt.Request().Header.Get("If-Match")
if etag != "" {
_, sequenceNo = SplitEtag(etag)
seq, err = strconv.Atoi(sequenceNo)
if err != nil {
return NewControllerError("unexpected error updating content type. invalid sequence number", err, http.StatusBadRequest)
}
}

body, err := io.ReadAll(ctxt.Request().Body)
if err != nil {
ctxt.Logger().Debugf("unexpected error reading request body: %s", err)
return NewControllerError("unexpected error reading request body", err, http.StatusBadRequest)
}
resource, err := resourceRepository.Initialize(ctxt.Request().Context(), logger, body)
//if the sequence number is not one more than the current sequence number then return an error
if seq != 0 && resource.GetSequenceNo() != seq+1 {
return NewControllerError("unexpected error updating content type. invalid sequence number", err, http.StatusPreconditionFailed)
}
//set etag in response header
ctxt.Response().Header().Set("ETag", fmt.Sprintf("%s.%d", resource.GetID(), resource.GetSequenceNo()))
if resource.GetSequenceNo() == 1 {
return ctxt.JSON(http.StatusCreated, resource)
} else {
return ctxt.JSON(http.StatusOK, resource)
}
}
}

// DefaultExecuteController handles the write operations that have a command associated with them
func DefaultExecuteController(logger Log, commandDispatcher CommandDispatcher, resourceRepository *ResourceRepository, api *openapi3.T, pathMap map[string]*openapi3.PathItem, operation map[string]*openapi3.Operation) echo.HandlerFunc {
var commandName string
var err error
var schema *openapi3.Schema
Expand Down Expand Up @@ -52,7 +91,6 @@ func DefaultWriteController(logger Log, commandDispatcher CommandDispatcher, res
return func(ctxt echo.Context) error {
var sequenceNo string
var seq int
var commandResponse interface{}

//getting etag from context
etag := ctxt.Request().Header.Get("If-Match")
Expand All @@ -64,13 +102,12 @@ func DefaultWriteController(logger Log, commandDispatcher CommandDispatcher, res
}
}

var resource *BasicResource
body, err := io.ReadAll(ctxt.Request().Body)
if err != nil {
ctxt.Logger().Debugf("unexpected error reading request body: %s", err)
return NewControllerError("unexpected error reading request body", err, http.StatusBadRequest)
}
resource, err = new(BasicResource).FromSchema(api, body)
resource, err := resourceRepository.Initialize(ctxt.Request().Context(), logger, body)
//not sure this is correct
payload, err := json.Marshal(&ResourceCreateParams{
Resource: resource,
Expand All @@ -89,28 +126,17 @@ func DefaultWriteController(logger Log, commandDispatcher CommandDispatcher, res
AccountID: GetAccount(ctxt.Request().Context()),
},
}
commandResponse, err = commandDispatcher.Dispatch(ctxt.Request().Context(), command, resourceRepository, ctxt.Logger())
_, err = commandDispatcher.Dispatch(ctxt.Request().Context(), command, resourceRepository, ctxt.Logger())
//an error handler `HTTPErrorHandler` can be defined on the echo instance to handle error responses
if err != nil {

}

//TODO the type of command and/or the api configuration should determine the status code
switch commandName {
case "create":
//set etag in response header
if entity, ok := commandResponse.(*BasicResource); ok {
ctxt.Response().Header().Set("ETag", fmt.Sprintf("%s.%d", entity.GetID(), entity.GetSequenceNo()))
return ctxt.JSON(http.StatusCreated, entity)
}
return ctxt.JSON(http.StatusCreated, commandResponse)
default:
//check to see if the response is a map or string
if stringResponse, ok := commandResponse.(string); ok {
return ctxt.String(http.StatusOK, stringResponse)
} else {
return ctxt.JSON(http.StatusOK, commandResponse)
}
//set etag in response header
ctxt.Response().Header().Set("ETag", fmt.Sprintf("%s.%d", resource.GetID(), resource.GetSequenceNo()))
if resource.GetSequenceNo() == 1 {
return ctxt.JSON(http.StatusCreated, resource)
} else {
return ctxt.JSON(http.StatusOK, resource)
}
}
}
Expand Down
28 changes: 19 additions & 9 deletions v2/rest/controllers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,29 @@ func TestDefaultWriteController(t *testing.T) {

},
}
t.Run("create a simple resource", func(t *testing.T) {
repository := &RepositoryMock{
PersistFunc: func(ctxt context.Context, logger rest.Log, resources []rest.Resource) []error {
t.Run("create a simple resource for the first time", func(t *testing.T) {
defaultProjection := &ProjectionMock{
GetByURIFunc: func(ctxt context.Context, logger rest.Log, uri string) (rest.Resource, error) {
return nil, nil
},
}
eventStore := &EventStoreMock{
AddSubscriberFunc: func(config rest.EventHandlerConfig) error {
return nil
},
}
params := rest.ResourceRepositoryParams{
EventStore: eventStore,
DefaultProjection: defaultProjection,
Config: schema,
}
result, err := rest.NewResourceRepository(params)
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
repository := result.Repository
commandDispatcher := &CommandDispatcherMock{
DispatchFunc: func(ctx context.Context, command *rest.Command, repository rest.Repository, logger rest.Log) (interface{}, error) {
DispatchFunc: func(ctx context.Context, command *rest.Command, repository *rest.ResourceRepository, logger rest.Log) (interface{}, error) {
return nil, nil
},
}
Expand All @@ -55,10 +70,5 @@ func TestDefaultWriteController(t *testing.T) {
if resp.Code != http.StatusCreated {
t.Errorf("expected status code %d, got %d", http.StatusCreated, resp.Code)
}

if len(commandDispatcher.DispatchCalls()) != 1 {
t.Errorf("expected dispatch to be called once, got %d", len(commandDispatcher.DispatchCalls()))
}

})
}
21 changes: 19 additions & 2 deletions v2/rest/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package rest

import (
"encoding/json"
"github.com/getkin/kin-openapi/openapi3"
"gorm.io/gorm"
)

type Event struct {
ID string `json:"id"`
gorm.Model
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
Meta EventMeta `json:"meta"`
Meta EventMeta `json:"meta" gorm:"embedded"`
Version int `json:"version"`
errors []error
}
Expand Down Expand Up @@ -53,3 +55,18 @@ func (e *Event) GetID() string {
//TODO implement me
panic("implement me")
}

func (e *Event) FromBytes(schema *openapi3.T, data []byte) (Resource, error) {
//TODO implement me
panic("implement me")
}

func (e *Event) IsValid() bool {
//TODO implement me
panic("implement me")
}

func (e *Event) GetErrors() []error {
//TODO implement me
panic("implement me")
}
20 changes: 10 additions & 10 deletions v2/rest/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import (

func TestDefaultEventDisptacher_AddSubscriber(t *testing.T) {
t.Run("add subscriber for event type only", func(t *testing.T) {
eventDispatcher := new(rest.GORMEventStore)
eventDispatcher := new(rest.GORMProjection)
err := eventDispatcher.AddSubscriber(rest.EventHandlerConfig{
Type: "create",
Handler: func(ctx context.Context, logger rest.Log, event rest.Event) error {
Handler: func(ctx context.Context, logger rest.Log, event *rest.Event) error {
return nil
},
})
Expand All @@ -31,11 +31,11 @@ func TestDefaultEventDisptacher_AddSubscriber(t *testing.T) {
}
})
t.Run("add subscriber for resource type and event", func(t *testing.T) {
eventDispatcher := new(rest.GORMEventStore)
eventDispatcher := new(rest.GORMProjection)
err := eventDispatcher.AddSubscriber(rest.EventHandlerConfig{
ResourceType: "Article",
Type: "create",
Handler: func(ctx context.Context, logger rest.Log, event rest.Event) error {
Handler: func(ctx context.Context, logger rest.Log, event *rest.Event) error {
return nil
},
})
Expand All @@ -55,7 +55,7 @@ func TestDefaultEventDisptacher_AddSubscriber(t *testing.T) {
}
})
t.Run("adding subscriber without handler should throw error", func(t *testing.T) {
eventDispatcher := new(rest.GORMEventStore)
eventDispatcher := new(rest.GORMProjection)
err := eventDispatcher.AddSubscriber(rest.EventHandlerConfig{
Type: "create",
})
Expand Down Expand Up @@ -83,10 +83,10 @@ func TestResourceRepository_Dispatch(t *testing.T) {
t.Run("should trigger resource specific handler and generic event type handler", func(t *testing.T) {
createHandlerHit := false
articleCreateHandlerHit := false
eventDispatcher := new(rest.GORMEventStore)
eventDispatcher := new(rest.GORMProjection)
err := eventDispatcher.AddSubscriber(rest.EventHandlerConfig{
Type: "create",
Handler: func(ctx context.Context, logger rest.Log, event rest.Event) error {
Handler: func(ctx context.Context, logger rest.Log, event *rest.Event) error {
createHandlerHit = true
return nil
},
Expand All @@ -97,20 +97,20 @@ func TestResourceRepository_Dispatch(t *testing.T) {
err = eventDispatcher.AddSubscriber(rest.EventHandlerConfig{
Type: "create",
ResourceType: "Article",
Handler: func(ctx context.Context, logger rest.Log, event rest.Event) error {
Handler: func(ctx context.Context, logger rest.Log, event *rest.Event) error {
articleCreateHandlerHit = true
return nil
},
})
if err != nil {
t.Errorf("expected no error, got %s", err)
}
errors := eventDispatcher.Dispatch(context.Background(), rest.Event{
errors := eventDispatcher.Dispatch(context.Background(), logger, &rest.Event{
Type: "create",
Meta: rest.EventMeta{
ResourceType: "Article",
},
}, logger)
})
if len(errors) != 0 {
t.Errorf("expected no errors, got %d", len(errors))
}
Expand Down
Loading

0 comments on commit c61640a

Please sign in to comment.