diff --git a/v2/api.yaml b/v2/api.yaml index c065c91a..c29fb23b 100644 --- a/v2/api.yaml +++ b/v2/api.yaml @@ -183,15 +183,6 @@ paths: x-file: "./controllers/rest/fixtures/staticF/index" 404: description: file not found - /health: - summary: Health Check - get: - x-controller: HealthCheck - responses: - 200: - description: Health Response - 500: - description: API Internal Error /api/json: get: operationId: Get API Details diff --git a/v2/rest/api.go b/v2/rest/api.go index f87cd982..a080637f 100644 --- a/v2/rest/api.go +++ b/v2/rest/api.go @@ -24,7 +24,7 @@ func registerHooks(lifecycle fx.Lifecycle, e *echo.Echo) { }) } -var API = fx.Module("rest", +var Core = fx.Module("weos-basic", fx.Provide( WeOSConfig, Config, @@ -37,5 +37,9 @@ var API = fx.Module("rest", NewGORMProjection, NewSecurityConfiguration, ), - fx.Invoke(RouteInitializer, registerHooks), + fx.Invoke(RouteInitializer), ) + +var API = fx.Module("rest", + Core, + fx.Invoke(registerHooks)) diff --git a/v2/rest/auth.go b/v2/rest/auth.go index 00ace5ab..2109f116 100644 --- a/v2/rest/auth.go +++ b/v2/rest/auth.go @@ -51,8 +51,8 @@ type SecurityConfiguration struct { AuthEnforcer *casbin.Enforcer } -func NewSecurityConfiguration(p SecurityParams) (result *SecurityConfiguration, err error) { - result = &SecurityConfiguration{ +func NewSecurityConfiguration(p SecurityParams) (result SecurityConfiguration, err error) { + result = SecurityConfiguration{ SecuritySchemes: make(map[string]Validator), } for name, schema := range p.Config.Components.SecuritySchemes { @@ -63,7 +63,7 @@ func NewSecurityConfiguration(p SecurityParams) (result *SecurityConfiguration, result.SecuritySchemes[name], err = new(OpenIDConnect).FromSchema(ctxt, schema.Value, p.HttpClient) default: err = fmt.Errorf("unsupported security scheme '%s'", name) - return nil, err + return result, err } } } @@ -71,7 +71,7 @@ func NewSecurityConfiguration(p SecurityParams) (result *SecurityConfiguration, //setup casbin enforcer adapter, err := gormadapter.NewAdapterByDB(p.GORMDB) if err != nil { - return nil, err + return result, err } //default REST permission model diff --git a/v2/rest/command.go b/v2/rest/command.go index 2ac9ca6b..44b98d6d 100644 --- a/v2/rest/command.go +++ b/v2/rest/command.go @@ -15,7 +15,7 @@ const DELETE_COMMAND = "delete" type CommandDispatcherParams struct { fx.In - CommandConfigs []CommandConfig `group:"commandHandlers"` + CommandConfigs []CommandConfig Logger Log } @@ -43,7 +43,7 @@ type DefaultCommandDispatcher struct { dispatch sync.Mutex } -func (e *DefaultCommandDispatcher) Dispatch(ctx context.Context, command *Command, logger Log, options *CommandOptions) (response *CommandResponse, err error) { +func (e *DefaultCommandDispatcher) Dispatch(ctx context.Context, command *Command, logger Log, options *CommandOptions) (response CommandResponse, err error) { var wg sync.WaitGroup var allHandlers []CommandHandler //first preference is handlers for specific command type and entity type @@ -73,7 +73,7 @@ func (e *DefaultCommandDispatcher) Dispatch(ctx context.Context, command *Comman } wg.Done() }() - response, err = handler(ctx, command, options.ResourceRepository, logger) + response, err = handler(ctx, command, logger, options) }() } diff --git a/v2/rest/config.go b/v2/rest/config.go index 89bbce02..49b21034 100644 --- a/v2/rest/config.go +++ b/v2/rest/config.go @@ -7,6 +7,7 @@ import ( "go.uber.org/fx" "net/url" "os" + "strings" ) // Config loads the OpenAPI spec from the environment @@ -17,7 +18,18 @@ func Config() (*openapi3.T, error) { if err == nil { return openapi3.NewLoader().LoadFromURI(turl) } else { - return openapi3.NewLoader().LoadFromFile(spec) + //read the file + content, err := os.ReadFile(spec) + if err != nil { + return nil, err + } + //change the $ref to another marker so that it doesn't get considered an environment variable WECON-1 + tempFile := strings.ReplaceAll(string(content), "$ref", "__ref__") + //replace environment variables in file + tempFile = os.ExpandEnv(string(tempFile)) + tempFile = strings.ReplaceAll(string(tempFile), "__ref__", "$ref") + content = []byte(tempFile) + return openapi3.NewLoader().LoadFromData(content) } } diff --git a/v2/rest/controllers.go b/v2/rest/controllers.go index 0452630f..f4ea8e3e 100644 --- a/v2/rest/controllers.go +++ b/v2/rest/controllers.go @@ -25,8 +25,6 @@ type ControllerParams struct { // DefaultWriteController handles the write operations (create, update, delete) func DefaultWriteController(p *ControllerParams) echo.HandlerFunc { - - var err error var commandName string var resourceType string for method, toperation := range p.Operation { @@ -44,11 +42,9 @@ func DefaultWriteController(p *ControllerParams) echo.HandlerFunc { } } //If there is a x-command extension then dispatch that command by default - if rawCommand, ok := toperation.Extensions["x-command"].(json.RawMessage); ok { - err := json.Unmarshal(rawCommand, &commandName) - if err != nil { - p.Logger.Fatalf("error unmarshalling command: %s", err) - } + var ok bool + if commandName, ok = toperation.Extensions["x-command"].(string); ok { + p.Logger.Debugf("command configured: %s", commandName) } //If there is a x-command-name extension then dispatch that command by default otherwise use the default command based on the operation type if commandName == "" { @@ -67,16 +63,17 @@ func DefaultWriteController(p *ControllerParams) echo.HandlerFunc { return func(ctxt echo.Context) error { var sequenceNo string - var seq int + var seq int64 //getting etag from context etag := ctxt.Request().Header.Get("If-Match") if etag != "" { _, sequenceNo = SplitEtag(etag) - seq, err = strconv.Atoi(sequenceNo) + tseq, err := strconv.Atoi(sequenceNo) if err != nil { return NewControllerError("unexpected error updating content type. invalid sequence number", err, http.StatusBadRequest) } + seq = int64(tseq) } body, err := io.ReadAll(ctxt.Request().Body) diff --git a/v2/rest/controllers_test.go b/v2/rest/controllers_test.go index 8a869f20..c624e054 100644 --- a/v2/rest/controllers_test.go +++ b/v2/rest/controllers_test.go @@ -60,8 +60,10 @@ func TestDefaultWriteController(t *testing.T) { } repository := result.Repository commandDispatcher := &CommandDispatcherMock{ - DispatchFunc: func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (*rest.CommandResponse, error) { - return nil, nil + DispatchFunc: func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (rest.CommandResponse, error) { + return rest.CommandResponse{ + Code: 200, + }, nil }, } controller := rest.DefaultWriteController(&rest.ControllerParams{ diff --git a/v2/rest/event.go b/v2/rest/event.go index ff991b6c..64cb0fa8 100644 --- a/v2/rest/event.go +++ b/v2/rest/event.go @@ -46,7 +46,7 @@ func (e *Event) GetType() string { panic("implement me") } -func (e *Event) GetSequenceNo() int { +func (e *Event) GetSequenceNo() int64 { //TODO implement me panic("implement me") } diff --git a/v2/rest/fixtures/blog.yaml b/v2/rest/fixtures/blog.yaml index 97f2ec2f..67ab538c 100644 --- a/v2/rest/fixtures/blog.yaml +++ b/v2/rest/fixtures/blog.yaml @@ -306,9 +306,6 @@ paths: post: operationId: Add Blog summary: Create Blog - x-projection: Default - x-event-dispatcher: Default - x-command-disptacher: Default requestBody: description: Blog info that is submitted required: true @@ -339,7 +336,6 @@ paths: type: integer - in: query name: l - x-alias: limit schema: type: integer - in: query @@ -475,6 +471,7 @@ paths: format: double summary: Update blog details operationId: Update Blog + x-command: CreateBlog requestBody: required: true content: diff --git a/v2/rest/gorm.go b/v2/rest/gorm.go index 2847ff94..7c7df49b 100644 --- a/v2/rest/gorm.go +++ b/v2/rest/gorm.go @@ -199,6 +199,8 @@ func NewGORMProjection(p GORMProjectionParams) (result GORMProjectionResult, err return result, err } } + //add handlers for create, update and delete + result = GORMProjectionResult{ Dispatcher: dispatcher, DefaultProjection: dispatcher, @@ -224,39 +226,38 @@ func (e *GORMProjection) Dispatch(ctx context.Context, logger Log, event *Event) //mutex helps keep state between routines var errors []error var wg sync.WaitGroup + var handlers []EventHandler + var ok bool + if globalHandlers := e.handlers[""]; globalHandlers != nil { + if handlers, ok = globalHandlers[event.Type]; ok { + + } + } if resourceTypeHandlers, ok := e.handlers[event.Meta.ResourceType]; ok { + if thandlers, ok := resourceTypeHandlers[event.Type]; ok { + handlers = append(handlers, thandlers...) + } + } - if handlers, ok := resourceTypeHandlers[event.Type]; ok { - //check to see if there were handlers registered for the event type that is not specific to a resource type - if event.Meta.ResourceType != "" { - if eventTypeHandlers, ok := e.handlers[""]; ok { - if ehandlers, ok := eventTypeHandlers[event.Type]; ok { - handlers = append(handlers, ehandlers...) - } + for i := 0; i < len(handlers); i++ { + handler := handlers[i] + wg.Add(1) + go func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf("handler panicked %s", r) } - } - for i := 0; i < len(handlers); i++ { - handler := handlers[i] - wg.Add(1) - go func() { - defer func() { - if r := recover(); r != nil { - logger.Errorf("handler panicked %s", r) - } - wg.Done() - }() - - err := handler(ctx, logger, event) - if err != nil { - errors = append(errors, err) - } + wg.Done() + }() - }() + err := handler(ctx, logger, event) + if err != nil { + errors = append(errors, err) } - wg.Wait() - } + }() } + wg.Wait() return errors } @@ -287,8 +288,16 @@ func (e *GORMProjection) GetSubscribers(resourceType string) map[string][]EventH } func (e *GORMProjection) GetByURI(ctxt context.Context, logger Log, uri string) (Resource, error) { - //TODO implement me - panic("implement me") + resource := new(BasicResource) + result := e.gormDB.Where("id = ?", uri).First(resource) + if result.Error != nil { + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, result.Error + } else { + return nil, nil + } + } + return resource, nil } func (e *GORMProjection) GetByKey(ctxt context.Context, identifiers map[string]interface{}) (Resource, error) { @@ -308,7 +317,6 @@ func (e *GORMProjection) GetByProperties(ctxt context.Context, identifiers map[s // Persist persists the events to the database func (e *GORMProjection) Persist(ctxt context.Context, logger Log, resources []Resource) (errs []error) { - //TODO not sure this casting is needed var events []*Event for _, resource := range resources { if event, ok := resource.(*Event); ok { diff --git a/v2/rest/initializers.go b/v2/rest/initializers.go index c28d83bb..d34d926d 100644 --- a/v2/rest/initializers.go +++ b/v2/rest/initializers.go @@ -87,6 +87,7 @@ func RouteInitializer(p RouteParams) (err error) { CommandDispatcher: p.CommandDispatcher, ResourceRepository: p.ResourceRepository, Schema: p.Config, + APIConfig: p.APIConfig, })) //set up the security middleware if there is a config setup if len(p.Config.Security) > 0 { @@ -94,6 +95,7 @@ func RouteInitializer(p RouteParams) (err error) { Logger: p.Logger, SecuritySchemes: p.SecuritySchemes, Schema: p.Config, + APIConfig: p.APIConfig, })) } @@ -116,9 +118,9 @@ func RouteInitializer(p RouteParams) (err error) { } } - var handler echo.HandlerFunc var methodsFound []string for method, operation := range pathItem.Operations() { + var handler echo.HandlerFunc methodsFound = append(methodsFound, method) if controller, ok := operation.Extensions["x-controller"].(string); ok { if c, ok := controllers[controller]; ok { @@ -127,6 +129,7 @@ func RouteInitializer(p RouteParams) (err error) { CommandDispatcher: p.CommandDispatcher, ResourceRepository: p.ResourceRepository, Schema: p.Config, + APIConfig: p.APIConfig, PathMap: map[string]*openapi3.PathItem{ path: pathItem, }, @@ -146,6 +149,7 @@ func RouteInitializer(p RouteParams) (err error) { CommandDispatcher: p.CommandDispatcher, ResourceRepository: p.ResourceRepository, Schema: p.Config, + APIConfig: p.APIConfig, PathMap: map[string]*openapi3.PathItem{ path: pathItem, }, @@ -159,6 +163,7 @@ func RouteInitializer(p RouteParams) (err error) { CommandDispatcher: p.CommandDispatcher, ResourceRepository: p.ResourceRepository, Schema: p.Config, + APIConfig: p.APIConfig, PathMap: map[string]*openapi3.PathItem{ path: pathItem, }, @@ -194,7 +199,9 @@ func RouteInitializer(p RouteParams) (err error) { allMiddleware = append(allMiddleware, pathMiddleware...) allMiddleware = append(allMiddleware, operationMiddleware...) // Add the middleware to the routes - p.Echo.Add(method, p.APIConfig.BasePath+path, handler, allMiddleware...) + re := regexp.MustCompile(`\{([a-zA-Z0-9\-_]+?)\}`) + echoPath := re.ReplaceAllString(path, `:$1`) + p.Echo.Add(method, p.APIConfig.BasePath+echoPath, handler, allMiddleware...) //setup security enforcer if authRaw, ok := operation.Extensions[AuthorizationConfigExtension]; ok { diff --git a/v2/rest/integration_test.go b/v2/rest/integration_test.go new file mode 100644 index 00000000..5787490f --- /dev/null +++ b/v2/rest/integration_test.go @@ -0,0 +1,126 @@ +package rest_test + +import ( + "encoding/json" + "github.com/labstack/echo/v4" + "github.com/wepala/weos/v2/rest" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" + "golang.org/x/net/context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +type RequestTest struct { + Title string + URL string + Body []byte + Headers map[string]string + Method string + ExpectedStatus int + ExpectedBody map[string]interface{} +} + +func TestIntegrations(t *testing.T) { + //setup server + var e *echo.Echo + //ctxt := context.Background() + os.Setenv("WEOS_PORT", "8681") + os.Setenv("WEOS_SPEC", "./fixtures/blog.yaml") + //use fx Module to start the server + Receivers := func() []rest.CommandConfig { + return []rest.CommandConfig{ + { + Type: "CreateBlog", + Handler: func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (response rest.CommandResponse, err error) { + return rest.CommandResponse{ + Code: 400, //this is set deliberately for testing + Body: map[string]interface{}{ + "title": "Test Blog", + }, + }, nil + }, + }, + } + } + app := fxtest.New(t, fx.Provide(Receivers), rest.Core, fx.Invoke(func(techo *echo.Echo) { + e = techo + })) + defer app.RequireStop() + app.RequireStart() + tests := []RequestTest{ + { + Title: "create organization", + URL: "/root", + //crate json ld body for the request + Body: []byte(`{"@context": "http://schema.org","@type": "Organization","name": "Wepala", "@id": "/root"}`), + Headers: map[string]string{"Content-Type": "application/ld+json"}, + Method: http.MethodPost, + ExpectedStatus: 201, + ExpectedBody: map[string]interface{}{"@context": "http://schema.org", "@type": "Organization", "name": "Wepala", "@id": "/root"}, + }, + { + Title: "create organization with a put request", + URL: "/root", + //crate json ld body for the request + Body: []byte(`{"@context": "http://schema.org","@type": "Organization","name": "Wepala", "@id": "/put"}`), + Headers: map[string]string{"Content-Type": "application/ld+json"}, + Method: http.MethodPut, + ExpectedStatus: 201, + ExpectedBody: map[string]interface{}{"@context": "http://schema.org", "@type": "Organization", "name": "Wepala", "@id": "/put"}, + }, + { + Title: "create organization with a patch request", + URL: "/root", + //crate json ld body for the request + Body: []byte(`{"@context": "http://schema.org","@type": "Organization","name": "Wepala", "@id": "/patch"}`), + Headers: map[string]string{"Content-Type": "application/ld+json"}, + Method: http.MethodPatch, + ExpectedStatus: 201, + ExpectedBody: map[string]interface{}{"@context": "http://schema.org", "@type": "Organization", "name": "Wepala", "@id": "/patch"}, + }, + { + Title: "create blog using command", + URL: "/blogs/1", + //crate json ld body for the request + Body: []byte(`{"title": "Test Blog"}`), + Headers: map[string]string{"Content-Type": "application/json"}, + Method: http.MethodPut, + ExpectedStatus: 400, + ExpectedBody: map[string]interface{}{"title": "Test Blog"}, + }, + } + + for _, test := range tests { + t.Run(test.Title, func(t *testing.T) { + body := strings.NewReader(string(test.Body)) + req := httptest.NewRequest(test.Method, test.URL, body) + req.Header.Set("Content-Type", test.Headers["Content-Type"]) + resp := httptest.NewRecorder() + defer func() { + err := resp.Result().Body.Close() + if err != nil { + t.Errorf("error closing response body: %s", err.Error()) + } + }() + e.ServeHTTP(resp, req) + if resp.Code != test.ExpectedStatus { + t.Errorf("expected status %d, got %d", test.ExpectedStatus, resp.Code) + } + resultPayload := make(map[string]interface{}) + err := json.Unmarshal(resp.Body.Bytes(), &resultPayload) + if err != nil { + t.Errorf("error unmarshalling response body: %s", err.Error()) + } + for key, value := range test.ExpectedBody { + if resultPayload[key] != value { + t.Errorf("expected %s to be %v, got %v", key, value, resultPayload[key]) + } + } + }) + } + +} diff --git a/v2/rest/interfaces.go b/v2/rest/interfaces.go index 372a600a..452fb5c6 100644 --- a/v2/rest/interfaces.go +++ b/v2/rest/interfaces.go @@ -17,7 +17,7 @@ type ( GlobalInitializer func(context.Context, *openapi3.T) (context.Context, error) OperationInitializer func(context.Context, string, string, *openapi3.T, *openapi3.PathItem, *openapi3.Operation) (context.Context, error) PathInitializer func(context.Context, string, *openapi3.T, *openapi3.PathItem) (context.Context, error) - CommandHandler func(ctx context.Context, command *Command, repository *ResourceRepository, logger Log) (response *CommandResponse, err error) + CommandHandler func(ctx context.Context, command *Command, logger Log, options *CommandOptions) (response CommandResponse, err error) EventHandler func(ctx context.Context, logger Log, event *Event) error ) @@ -39,7 +39,7 @@ type Repository interface { } type CommandDispatcher interface { - Dispatch(ctx context.Context, command *Command, logger Log, options *CommandOptions) (response *CommandResponse, err error) + Dispatch(ctx context.Context, command *Command, logger Log, options *CommandOptions) (response CommandResponse, err error) AddSubscriber(command CommandConfig) map[string][]CommandHandler GetSubscribers() map[string][]CommandHandler } diff --git a/v2/rest/resource.go b/v2/rest/resource.go index ac3aefff..71ed7a27 100644 --- a/v2/rest/resource.go +++ b/v2/rest/resource.go @@ -17,7 +17,7 @@ type EventSourced interface { type Resource interface { EventSourced GetType() string - GetSequenceNo() int + GetSequenceNo() int64 GetID() string FromBytes(schema *openapi3.T, data []byte) (Resource, error) IsValid() bool @@ -32,9 +32,9 @@ type BasicResource struct { type ResourceMetadata struct { ID string `gorm:"primaryKey"` - SequenceNo int + SequenceNo int64 Type string - Version int64 + Version int UserID string AccountID string } @@ -106,13 +106,14 @@ func (r *BasicResource) GetType() string { return r.Metadata.Type } -func (r *BasicResource) GetSequenceNo() int { +func (r *BasicResource) GetSequenceNo() int64 { return r.Metadata.SequenceNo } // NewChange adds a new event to the list of new events func (r *BasicResource) NewChange(event *Event) { r.Metadata.SequenceNo += 1 + event.Meta.SequenceNo = r.Metadata.SequenceNo r.newEvents = append(r.newEvents, event) } diff --git a/v2/rest/rest_mocks_test.go b/v2/rest/rest_mocks_test.go index 23215ef3..c680c4e5 100644 --- a/v2/rest/rest_mocks_test.go +++ b/v2/rest/rest_mocks_test.go @@ -1125,7 +1125,7 @@ var _ rest.CommandDispatcher = &CommandDispatcherMock{} // AddSubscriberFunc: func(command rest.CommandConfig) map[string][]rest.CommandHandler { // panic("mock out the AddSubscriber method") // }, -// DispatchFunc: func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (*rest.CommandResponse, error) { +// DispatchFunc: func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (rest.CommandResponse, error) { // panic("mock out the Dispatch method") // }, // GetSubscribersFunc: func() map[string][]rest.CommandHandler { @@ -1142,7 +1142,7 @@ type CommandDispatcherMock struct { AddSubscriberFunc func(command rest.CommandConfig) map[string][]rest.CommandHandler // DispatchFunc mocks the Dispatch method. - DispatchFunc func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (*rest.CommandResponse, error) + DispatchFunc func(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (rest.CommandResponse, error) // GetSubscribersFunc mocks the GetSubscribers method. GetSubscribersFunc func() map[string][]rest.CommandHandler @@ -1207,7 +1207,7 @@ func (mock *CommandDispatcherMock) AddSubscriberCalls() []struct { } // Dispatch calls DispatchFunc. -func (mock *CommandDispatcherMock) Dispatch(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (*rest.CommandResponse, error) { +func (mock *CommandDispatcherMock) Dispatch(ctx context.Context, command *rest.Command, logger rest.Log, options *rest.CommandOptions) (rest.CommandResponse, error) { if mock.DispatchFunc == nil { panic("CommandDispatcherMock.DispatchFunc: method is nil but CommandDispatcher.Dispatch was just called") } diff --git a/v2/server.go b/v2/server.go index 39ca93f4..835c8344 100644 --- a/v2/server.go +++ b/v2/server.go @@ -22,10 +22,5 @@ func main() { //use fx Module to start the server fx.New( rest.API, - //fx.WithLogger(func(log *zap.Logger) fxevent.Logger { - // return &fxevent.ZapLogger{ - // Logger: log, - // } - //}), ).Run() }