├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── core ├── command.go ├── command_test.go ├── context.go ├── core_test.go ├── coremocks │ ├── command.go │ ├── entity.go │ ├── event.go │ ├── marshaller.go │ ├── reply.go │ ├── saga_data.go │ └── snapshot.go ├── coretest │ ├── commands.go │ ├── entities.go │ ├── events.go │ ├── marshaller.go │ └── replies.go ├── entity.go ├── event.go ├── event_test.go ├── register_types │ └── register_types.go ├── registry.go ├── reply.go ├── reply_test.go ├── saga_data.go ├── saga_data_test.go ├── snapshot.go ├── snapshot_test.go └── testdata │ ├── core_test.testCommand.golden │ ├── core_test.testEvent.golden │ ├── core_test.testReply.golden │ ├── core_test.testSagaData.golden │ └── core_test.testSnapshot.golden ├── doc.go ├── es ├── aggregate.go ├── aggregate_root.go ├── aggregate_root_options.go ├── aggregate_root_repository.go ├── aggregate_root_store.go └── snapshot_strategies.go ├── go.mod ├── go.sum ├── grpc └── context.go ├── http └── context.go ├── inmem ├── consumer.go ├── consumer_options.go ├── event_store.go ├── event_store_options.go ├── producer.go ├── producer_options.go ├── saga_instance_store.go ├── snapshot_store.go └── snapshot_store_options.go ├── log ├── logger.go ├── logmocks │ └── logger.go └── logtest │ └── logger.go ├── msg ├── command.go ├── command_dispatcher.go ├── command_dispatcher_options.go ├── command_dispatcher_test.go ├── constants.go ├── consumer.go ├── entity_event.go ├── entity_event_dispatcher.go ├── entity_event_dispatcher_options.go ├── entity_event_dispatcher_test.go ├── event.go ├── event_dispatcher.go ├── event_dispatcher_options.go ├── event_dispatcher_test.go ├── headers.go ├── message.go ├── message_options.go ├── message_receiver.go ├── message_receiver_test.go ├── msgmocks │ ├── command_message_publisher.go │ ├── consumer.go │ ├── entity_event_message_publisher.go │ ├── event_message_publisher.go │ ├── message_publisher.go │ ├── message_receiver.go │ ├── message_subscriber.go │ ├── producer.go │ └── reply_message_publisher.go ├── msgtest │ ├── commands.go │ ├── consumer.go │ ├── entity.go │ ├── events.go │ ├── message_receiver.go │ ├── producer.go │ ├── replies.go │ └── reply_message_publisher.go ├── producer.go ├── publisher.go ├── publisher_options.go ├── publisher_test.go ├── register_types.go ├── reply.go ├── reply_builder.go ├── reply_builder_test.go ├── reply_types.go ├── subscriber.go ├── subscriber_options.go └── subscriber_test.go ├── outbox ├── constants.go ├── message.go ├── message_processor.go ├── message_store.go ├── polling_processor.go └── polling_processor_options.go ├── retry ├── backoff.go ├── backoff_options.go ├── constant_backoff.go ├── constants.go ├── errors.go ├── exponential_backoff.go └── retryer.go └── saga ├── command.go ├── command_dispatcher.go ├── command_dispatcher_options.go ├── command_dispatcher_test.go ├── constants.go ├── definition.go ├── instance.go ├── instance_store.go ├── local_step.go ├── message_options.go ├── orchestrator.go ├── orchestrator_options.go ├── remote_step.go ├── remote_step_action.go ├── remote_step_action_options.go ├── step.go ├── step_context.go └── step_results.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = false 9 | max_line_length = 120 10 | tab_width = 2 11 | 12 | [{*.go, *.go2}] 13 | indent_size = 4 14 | indent_style = tab 15 | tab_width = 4 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | golangci: 12 | strategy: 13 | matrix: 14 | go-version: [ 1.15.x ] 15 | os: [ macos-latest, windows-latest ] 16 | name: lint 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: golangci-lint 21 | uses: golangci/golangci-lint-action@v2 22 | with: 23 | version: v1.33 24 | 25 | test: 26 | strategy: 27 | matrix: 28 | go-version: [ 1.15.x ] 29 | os: [ ubuntu-latest, macos-latest, windows-latest ] 30 | runs-on: ${{ matrix.os }} 31 | steps: 32 | - name: Install Go 33 | uses: actions/setup-go@v2 34 | with: 35 | go-version: ${{ matrix.go-version }} 36 | - name: Checkout code 37 | uses: actions/checkout@v2 38 | - name: Test 39 | run: go test ./... 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/jetbrains+all,macos,vim,windows,dotenv 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains+all,macos,vim,windows,dotenv 3 | 4 | ### dotenv ### 5 | .env*.local 6 | 7 | out/ 8 | 9 | ### JetBrains+all Patch ### 10 | # Ignores the whole .idea folder and all .iml files 11 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 12 | 13 | .idea/ 14 | 15 | ### macOS ### 16 | # General 17 | .DS_Store 18 | .AppleDouble 19 | .LSOverride 20 | 21 | # Icon must end with two \r 22 | Icon 23 | 24 | # Thumbnails 25 | ._* 26 | 27 | # Files that might appear in the root of a volume 28 | .DocumentRevisions-V100 29 | .fseventsd 30 | .Spotlight-V100 31 | .TemporaryItems 32 | .Trashes 33 | .VolumeIcon.icns 34 | .com.apple.timemachine.donotpresent 35 | 36 | # Directories potentially created on remote AFP share 37 | .AppleDB 38 | .AppleDesktop 39 | Network Trash Folder 40 | Temporary Items 41 | .apdisk 42 | 43 | ### Vim ### 44 | # Swap 45 | [._]*.s[a-v][a-z] 46 | !*.svg # comment out if you don't need vector files 47 | [._]*.sw[a-p] 48 | [._]s[a-rt-v][a-z] 49 | [._]ss[a-gi-z] 50 | [._]sw[a-p] 51 | 52 | # Session 53 | Session.vim 54 | Sessionx.vim 55 | 56 | # Temporary 57 | .netrwhist 58 | *~ 59 | # Auto-generated tag files 60 | tags 61 | # Persistent undo 62 | [._]*.un~ 63 | 64 | ### Windows ### 65 | # Windows thumbnail cache files 66 | Thumbs.db 67 | Thumbs.db:encryptable 68 | ehthumbs.db 69 | ehthumbs_vista.db 70 | 71 | # Dump file 72 | *.stackdump 73 | 74 | # Folder config file 75 | [Dd]esktop.ini 76 | 77 | # Recycle Bin used on file shares 78 | $RECYCLE.BIN/ 79 | 80 | # Windows Installer files 81 | *.cab 82 | *.msi 83 | *.msix 84 | *.msm 85 | *.msp 86 | 87 | # Windows shortcuts 88 | *.lnk 89 | 90 | # End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,macos,vim,windows,dotenv 91 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Michael Stack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/stackus/edat/workflows/CI/badge.svg) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/stackus/edat)](https://goreportcard.com/report/github.com/stackus/edat) 3 | [![](https://godoc.org/github.com/stackus/edat?status.svg)](https://pkg.go.dev/github.com/stackus/edat) 4 | 5 | # edat - Event-Driven Architecture Toolkit 6 | 7 | edat is an event-driven architecture library for Go. 8 | 9 | ## Installation 10 | 11 | go get -u github.com/stackus/edat 12 | 13 | ## Prerequisites 14 | 15 | Go 1.15 16 | 17 | ## Features 18 | 19 | edat provides opinionated plumbing to help with many aspects of the development of an event-driven application. 20 | 21 | - Basic pubsub for events 22 | - Asynchronous command and reply messaging 23 | - Event sourcing 24 | - Entity change publication 25 | - Orchestrated sagas 26 | - Transactional Outbox 27 | 28 | ## Examples 29 | 30 | [FTGOGO](https://github.com/stackus/ftgogo) A golang rewrite of the FTGO Eventuate demonstration application using edat. 31 | 32 | ## TODOs 33 | 34 | - Documentation 35 | - Wiki Examples & Quickstart 36 | - Tests, tests, and more tests 37 | 38 | ## Support Libraries 39 | 40 | ### Stores 41 | 42 | - [edat-pgx](https://github.com/stackus/edat-pgx) Postgres 43 | 44 | ### Event Streams 45 | 46 | - [edat-stan](https://github.com/stackus/edat-stan) NATS Streaming 47 | - [edat-pgx](https://github.com/stackus/edat-pgx) Postgres (outbox store and message producer) 48 | 49 | ### Marshallers 50 | 51 | - [edat-msgpack](https://github.com/stackus/edat-msgpack) MessagePack 52 | 53 | ## Contributing 54 | 55 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 56 | 57 | Please make sure to update tests as appropriate. 58 | 59 | ## License 60 | 61 | MIT 62 | -------------------------------------------------------------------------------- /core/command.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | // Command interface 9 | type Command interface { 10 | CommandName() string 11 | } 12 | 13 | // SerializeCommand serializes commands with a registered marshaller 14 | func SerializeCommand(v Command) ([]byte, error) { 15 | return marshal(v.CommandName(), v) 16 | } 17 | 18 | // DeserializeCommand deserializes the command data using a registered marshaller returning a *Command 19 | func DeserializeCommand(commandName string, data []byte) (Command, error) { 20 | cmd, err := unmarshal(commandName, data) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if cmd != nil { 26 | if _, ok := cmd.(Command); !ok { 27 | return nil, fmt.Errorf("`%s` was registered but not registered as a command", commandName) 28 | } 29 | } 30 | 31 | return cmd.(Command), nil 32 | } 33 | 34 | // RegisterCommands registers one or more commands with a registered marshaller 35 | // 36 | // Register commands using any form desired "&MyCommand{}", "MyCommand{}", "(*MyCommand)(nil)" 37 | // 38 | // Commands must be registered after first registering a marshaller you wish to use 39 | func RegisterCommands(commands ...Command) { 40 | for _, command := range commands { 41 | if v := reflect.ValueOf(command); v.Kind() == reflect.Ptr && v.Pointer() == 0 { 42 | commandName := reflect.Zero(reflect.TypeOf(command).Elem()).Interface().(Command).CommandName() 43 | registerType(commandName, command) 44 | } else { 45 | registerType(command.CommandName(), command) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/command_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/core/coretest" 9 | ) 10 | 11 | type ( 12 | testCommand struct{ Value string } 13 | unregisteredCommand struct{ Value string } 14 | ) 15 | 16 | func (testCommand) CommandName() string { return "core_test.testCommand" } 17 | func (unregisteredCommand) CommandName() string { return "core_test.unregisteredCommand" } 18 | 19 | var ( 20 | testCmd = &testCommand{"command"} 21 | unregisteredCmd = &unregisteredCommand{"command"} 22 | ) 23 | 24 | func TestDeserializeCommand(t *testing.T) { 25 | type args struct { 26 | commandName string 27 | data []byte 28 | } 29 | 30 | testMarshaller := coretest.NewTestMarshaller() 31 | core.RegisterDefaultMarshaller(testMarshaller) 32 | core.RegisterCommands(testCommand{}) 33 | core.RegisterEvents(testEvent{}) 34 | 35 | tests := map[string]struct { 36 | args args 37 | want core.Command 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | args: args{ 42 | commandName: testCommand{}.CommandName(), 43 | data: getGoldenFileData(t, testCommand{}.CommandName()), 44 | }, 45 | want: testCmd, 46 | wantErr: false, 47 | }, 48 | "SuccessEmpty": { 49 | args: args{ 50 | commandName: testCommand{}.CommandName(), 51 | data: []byte("{}"), 52 | }, 53 | want: &testCommand{}, 54 | wantErr: false, 55 | }, 56 | "FailureNoData": { 57 | args: args{ 58 | commandName: testCommand{}.CommandName(), 59 | }, 60 | want: nil, 61 | wantErr: true, 62 | }, 63 | "FailureWrongType": { 64 | args: args{ 65 | commandName: testEvent{}.EventName(), 66 | data: []byte("{}"), 67 | }, 68 | want: nil, 69 | wantErr: true, 70 | }, 71 | "FailureUnregistered": { 72 | args: args{ 73 | commandName: unregisteredCommand{}.CommandName(), 74 | }, 75 | want: nil, 76 | wantErr: true, 77 | }, 78 | } 79 | for name, tt := range tests { 80 | t.Run(name, func(t *testing.T) { 81 | got, err := core.DeserializeCommand(tt.args.commandName, tt.args.data) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("DeserializeCommand() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | if !reflect.DeepEqual(got, tt.want) { 87 | t.Errorf("DeserializeCommand() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestSerializeCommand(t *testing.T) { 94 | type args struct { 95 | v core.Command 96 | } 97 | 98 | testMarshaller := coretest.NewTestMarshaller() 99 | core.RegisterDefaultMarshaller(testMarshaller) 100 | core.RegisterCommands(testCommand{}) 101 | 102 | tests := map[string]struct { 103 | args args 104 | want []byte 105 | wantErr bool 106 | }{ 107 | "Success": { 108 | args: args{testCmd}, 109 | want: getGoldenFileData(t, testCommand{}.CommandName()), 110 | wantErr: false, 111 | }, 112 | "SuccessEmpty": { 113 | args: args{testCommand{}}, 114 | want: []byte(`{"Value":""}`), 115 | wantErr: false, 116 | }, 117 | "FailureUnregistered": { 118 | args: args{unregisteredCmd}, 119 | want: nil, 120 | wantErr: true, 121 | }, 122 | } 123 | for name, tt := range tests { 124 | t.Run(name, func(t *testing.T) { 125 | got, err := core.SerializeCommand(tt.args.v) 126 | if (err != nil) != tt.wantErr { 127 | t.Errorf("SerializeCommand() error = %v, wantErr %v", err, tt.wantErr) 128 | return 129 | } 130 | if !reflect.DeepEqual(got, tt.want) { 131 | t.Errorf("SerializeCommand() got = %v, want %v", string(got), string(tt.want)) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/context.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type contextKey int 8 | 9 | // Known contextKey key values 10 | const ( 11 | requestIDKey contextKey = iota + 1 12 | correlationIDKey 13 | causationIDKey 14 | ) 15 | 16 | // GetRequestID returns the RequestID from the context or a blank if not set 17 | func GetRequestID(ctx context.Context) string { 18 | requestID := ctx.Value(requestIDKey) 19 | if requestID == nil { 20 | return "" 21 | } 22 | 23 | return requestID.(string) 24 | } 25 | 26 | // GetCorrelationID returns the CorrelationID from the context or a blank if not set 27 | // 28 | // In a long line of events, commands and messages this ID will match the original RequestID 29 | func GetCorrelationID(ctx context.Context) string { 30 | correlationID := ctx.Value(correlationIDKey) 31 | if correlationID == nil { 32 | return GetRequestID(ctx) 33 | } 34 | 35 | return correlationID.(string) 36 | } 37 | 38 | // GetCausationID returns the CausationID from the context or a blank if not set 39 | // 40 | // In a long line of events, commands and messages this ID will match the previous RequestID 41 | func GetCausationID(ctx context.Context) string { 42 | causationID := ctx.Value(causationIDKey) 43 | if causationID == nil { 44 | return GetRequestID(ctx) 45 | } 46 | 47 | return causationID.(string) 48 | } 49 | 50 | // SetRequestContext sets the Request, Correlation, and Causation IDs on the context 51 | // 52 | // Correlation and Causation IDs will use the RequestID if blank ID values are provided 53 | func SetRequestContext(ctx context.Context, requestID, correlationID, causationID string) context.Context { 54 | ctx = context.WithValue(ctx, requestIDKey, requestID) 55 | 56 | // CorrelationIDs point back to the first request 57 | if correlationID == "" { 58 | ctx = context.WithValue(ctx, correlationIDKey, requestID) 59 | } else { 60 | ctx = context.WithValue(ctx, correlationIDKey, correlationID) 61 | } 62 | 63 | // CausationIDs point back to the previous request 64 | if causationID == "" { 65 | ctx = context.WithValue(ctx, causationIDKey, requestID) 66 | } else { 67 | ctx = context.WithValue(ctx, causationIDKey, causationID) 68 | } 69 | return ctx 70 | } 71 | -------------------------------------------------------------------------------- /core/core_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "flag" 5 | "io/ioutil" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | var update = flag.Bool("update", false, "update golden file") 11 | 12 | func getGoldenFileData(t *testing.T, fileName string) []byte { 13 | golden := filepath.Join("testdata", fileName+".golden") 14 | // if *update { 15 | // if err := ioutil.WriteFile(golden, actual, 0644); err != nil { 16 | // t.Fatalf("Error writing golden file for filename=%s: %s", fileName, err) 17 | // } 18 | // } 19 | expected, err := ioutil.ReadFile(golden) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | return expected 24 | } 25 | -------------------------------------------------------------------------------- /core/coremocks/command.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Command is an autogenerated mock type for the Command type 8 | type Command struct { 9 | mock.Mock 10 | } 11 | 12 | // CommandName provides a mock function with given fields: 13 | func (_m *Command) CommandName() string { 14 | ret := _m.Called() 15 | 16 | var r0 string 17 | if rf, ok := ret.Get(0).(func() string); ok { 18 | r0 = rf() 19 | } else { 20 | r0 = ret.Get(0).(string) 21 | } 22 | 23 | return r0 24 | } 25 | -------------------------------------------------------------------------------- /core/coremocks/entity.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import ( 6 | core "github.com/stackus/edat/core" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Entity is an autogenerated mock type for the Entity type 11 | type Entity struct { 12 | mock.Mock 13 | } 14 | 15 | // AddEvent provides a mock function with given fields: events 16 | func (_m *Entity) AddEvent(events ...core.Event) { 17 | _va := make([]interface{}, len(events)) 18 | for _i := range events { 19 | _va[_i] = events[_i] 20 | } 21 | var _ca []interface{} 22 | _ca = append(_ca, _va...) 23 | _m.Called(_ca...) 24 | } 25 | 26 | // ClearEvents provides a mock function with given fields: 27 | func (_m *Entity) ClearEvents() { 28 | _m.Called() 29 | } 30 | 31 | // EntityName provides a mock function with given fields: 32 | func (_m *Entity) EntityName() string { 33 | ret := _m.Called() 34 | 35 | var r0 string 36 | if rf, ok := ret.Get(0).(func() string); ok { 37 | r0 = rf() 38 | } else { 39 | r0 = ret.Get(0).(string) 40 | } 41 | 42 | return r0 43 | } 44 | 45 | // Events provides a mock function with given fields: 46 | func (_m *Entity) Events() []core.Event { 47 | ret := _m.Called() 48 | 49 | var r0 []core.Event 50 | if rf, ok := ret.Get(0).(func() []core.Event); ok { 51 | r0 = rf() 52 | } else { 53 | if ret.Get(0) != nil { 54 | r0 = ret.Get(0).([]core.Event) 55 | } 56 | } 57 | 58 | return r0 59 | } 60 | 61 | // ID provides a mock function with given fields: 62 | func (_m *Entity) ID() string { 63 | ret := _m.Called() 64 | 65 | var r0 string 66 | if rf, ok := ret.Get(0).(func() string); ok { 67 | r0 = rf() 68 | } else { 69 | r0 = ret.Get(0).(string) 70 | } 71 | 72 | return r0 73 | } 74 | -------------------------------------------------------------------------------- /core/coremocks/event.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Event is an autogenerated mock type for the Event type 8 | type Event struct { 9 | mock.Mock 10 | } 11 | 12 | // EventName provides a mock function with given fields: 13 | func (_m *Event) EventName() string { 14 | ret := _m.Called() 15 | 16 | var r0 string 17 | if rf, ok := ret.Get(0).(func() string); ok { 18 | r0 = rf() 19 | } else { 20 | r0 = ret.Get(0).(string) 21 | } 22 | 23 | return r0 24 | } 25 | -------------------------------------------------------------------------------- /core/coremocks/marshaller.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import ( 6 | reflect "reflect" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // Marshaller is an autogenerated mock type for the Marshaller type 12 | type Marshaller struct { 13 | mock.Mock 14 | } 15 | 16 | // GetType provides a mock function with given fields: typeName 17 | func (_m *Marshaller) GetType(typeName string) reflect.Type { 18 | ret := _m.Called(typeName) 19 | 20 | var r0 reflect.Type 21 | if rf, ok := ret.Get(0).(func(string) reflect.Type); ok { 22 | r0 = rf(typeName) 23 | } else { 24 | if ret.Get(0) != nil { 25 | r0 = ret.Get(0).(reflect.Type) 26 | } 27 | } 28 | 29 | return r0 30 | } 31 | 32 | // Marshal provides a mock function with given fields: _a0 33 | func (_m *Marshaller) Marshal(_a0 interface{}) ([]byte, error) { 34 | ret := _m.Called(_a0) 35 | 36 | var r0 []byte 37 | if rf, ok := ret.Get(0).(func(interface{}) []byte); ok { 38 | r0 = rf(_a0) 39 | } else { 40 | if ret.Get(0) != nil { 41 | r0 = ret.Get(0).([]byte) 42 | } 43 | } 44 | 45 | var r1 error 46 | if rf, ok := ret.Get(1).(func(interface{}) error); ok { 47 | r1 = rf(_a0) 48 | } else { 49 | r1 = ret.Error(1) 50 | } 51 | 52 | return r0, r1 53 | } 54 | 55 | // RegisterType provides a mock function with given fields: typeName, v 56 | func (_m *Marshaller) RegisterType(typeName string, v reflect.Type) { 57 | _m.Called(typeName, v) 58 | } 59 | 60 | // Unmarshal provides a mock function with given fields: _a0, _a1 61 | func (_m *Marshaller) Unmarshal(_a0 []byte, _a1 interface{}) error { 62 | ret := _m.Called(_a0, _a1) 63 | 64 | var r0 error 65 | if rf, ok := ret.Get(0).(func([]byte, interface{}) error); ok { 66 | r0 = rf(_a0, _a1) 67 | } else { 68 | r0 = ret.Error(0) 69 | } 70 | 71 | return r0 72 | } 73 | -------------------------------------------------------------------------------- /core/coremocks/reply.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Reply is an autogenerated mock type for the Reply type 8 | type Reply struct { 9 | mock.Mock 10 | } 11 | 12 | // ReplyName provides a mock function with given fields: 13 | func (_m *Reply) ReplyName() string { 14 | ret := _m.Called() 15 | 16 | var r0 string 17 | if rf, ok := ret.Get(0).(func() string); ok { 18 | r0 = rf() 19 | } else { 20 | r0 = ret.Get(0).(string) 21 | } 22 | 23 | return r0 24 | } 25 | -------------------------------------------------------------------------------- /core/coremocks/saga_data.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // SagaData is an autogenerated mock type for the SagaData type 8 | type SagaData struct { 9 | mock.Mock 10 | } 11 | 12 | // SagaDataName provides a mock function with given fields: 13 | func (_m *SagaData) SagaDataName() string { 14 | ret := _m.Called() 15 | 16 | var r0 string 17 | if rf, ok := ret.Get(0).(func() string); ok { 18 | r0 = rf() 19 | } else { 20 | r0 = ret.Get(0).(string) 21 | } 22 | 23 | return r0 24 | } 25 | -------------------------------------------------------------------------------- /core/coremocks/snapshot.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package coremocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Snapshot is an autogenerated mock type for the Snapshot type 8 | type Snapshot struct { 9 | mock.Mock 10 | } 11 | 12 | // SnapshotName provides a mock function with given fields: 13 | func (_m *Snapshot) SnapshotName() string { 14 | ret := _m.Called() 15 | 16 | var r0 string 17 | if rf, ok := ret.Get(0).(func() string); ok { 18 | r0 = rf() 19 | } else { 20 | r0 = ret.Get(0).(string) 21 | } 22 | 23 | return r0 24 | } 25 | -------------------------------------------------------------------------------- /core/coretest/commands.go: -------------------------------------------------------------------------------- 1 | package coretest 2 | 3 | import ( 4 | "github.com/stackus/edat/core/coremocks" 5 | ) 6 | 7 | type ( 8 | Command struct{ Value string } 9 | UnregisteredCommand struct{ Value string } 10 | ) 11 | 12 | func (Command) CommandName() string { return "coretest.Command" } 13 | func (UnregisteredCommand) CommandName() string { return "coretest.UnregisteredCommand" } 14 | 15 | func MockCommand(setup func(m *coremocks.Command)) *coremocks.Command { 16 | m := &coremocks.Command{} 17 | setup(m) 18 | return m 19 | } 20 | -------------------------------------------------------------------------------- /core/coretest/entities.go: -------------------------------------------------------------------------------- 1 | package coretest 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | "github.com/stackus/edat/core/coremocks" 6 | ) 7 | 8 | type Entity struct { 9 | core.EntityBase 10 | Value string 11 | } 12 | 13 | func (Entity) EntityName() string { return "msgtest.Entity" } 14 | func (Entity) ID() string { return "entity-id" } 15 | func (Entity) Events() []core.Event { return []core.Event{&Event{}} } 16 | 17 | func MockEntity(setup func(m *coremocks.Entity)) *coremocks.Entity { 18 | m := &coremocks.Entity{} 19 | setup(m) 20 | return m 21 | } 22 | -------------------------------------------------------------------------------- /core/coretest/events.go: -------------------------------------------------------------------------------- 1 | package coretest 2 | 3 | import ( 4 | "github.com/stackus/edat/core/coremocks" 5 | ) 6 | 7 | type ( 8 | Event struct{ Value string } 9 | UnregisteredEvent struct{ Value string } 10 | ) 11 | 12 | func (Event) EventName() string { return "coretest.Event" } 13 | func (UnregisteredEvent) EventName() string { return "coretest.UnregisteredEvent" } 14 | 15 | func MockEvent(setup func(m *coremocks.Event)) *coremocks.Event { 16 | m := &coremocks.Event{} 17 | setup(m) 18 | return m 19 | } 20 | -------------------------------------------------------------------------------- /core/coretest/marshaller.go: -------------------------------------------------------------------------------- 1 | package coretest 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "sync" 7 | ) 8 | 9 | // TestMarshaller returns a Marshaller for testing purposes 10 | // 11 | // JSON encoding is used for the Marshal and Unmarshal methods. 12 | // 13 | // The registered types may be reset using Reset() at any time while testing. 14 | type TestMarshaller struct { 15 | types map[string]reflect.Type 16 | mu sync.Mutex 17 | } 18 | 19 | // NewTestMarshaller constructs a new TestMarshaller 20 | func NewTestMarshaller() *TestMarshaller { 21 | return &TestMarshaller{ 22 | types: map[string]reflect.Type{}, 23 | mu: sync.Mutex{}, 24 | } 25 | } 26 | 27 | // Marshal returns v in byte form 28 | func (*TestMarshaller) Marshal(v interface{}) ([]byte, error) { return json.Marshal(v) } 29 | 30 | // Unmarshal returns the bytes marshalled into v 31 | func (*TestMarshaller) Unmarshal(data []byte, v interface{}) error { return json.Unmarshal(data, v) } 32 | 33 | // GetType returns the reflect.Type if it has been registered 34 | func (m *TestMarshaller) GetType(typeName string) reflect.Type { return m.types[typeName] } 35 | 36 | // RegisterType registers a new reflect.Type for the given name key 37 | func (m *TestMarshaller) RegisterType(typeName string, v reflect.Type) { 38 | m.mu.Lock() 39 | defer m.mu.Unlock() 40 | m.types[typeName] = v 41 | } 42 | 43 | // Reset will remove all previously registered types 44 | func (m *TestMarshaller) Reset() { 45 | m.mu.Lock() 46 | defer m.mu.Unlock() 47 | m.types = map[string]reflect.Type{} 48 | } 49 | -------------------------------------------------------------------------------- /core/coretest/replies.go: -------------------------------------------------------------------------------- 1 | package coretest 2 | 3 | import ( 4 | "github.com/stackus/edat/core/coremocks" 5 | ) 6 | 7 | type ( 8 | Reply struct{ Value string } 9 | UnregisteredReply struct{ Value string } 10 | ) 11 | 12 | func (Reply) ReplyName() string { return "coretest.Reply" } 13 | func (UnregisteredReply) ReplyName() string { return "coretest.UnregisteredReply" } 14 | 15 | func MockReply(setup func(m *coremocks.Reply)) *coremocks.Reply { 16 | m := &coremocks.Reply{} 17 | setup(m) 18 | return m 19 | } 20 | -------------------------------------------------------------------------------- /core/entity.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // Entity have identity and change tracking in the form of events 4 | type Entity interface { 5 | ID() string 6 | EntityName() string 7 | Events() []Event 8 | AddEvent(events ...Event) 9 | ClearEvents() 10 | } 11 | 12 | // EntityBase provides entities a base to build on 13 | type EntityBase struct { 14 | events []Event 15 | } 16 | 17 | // Events returns all tracked changes made to the Entity as Events 18 | func (e *EntityBase) Events() []Event { 19 | return e.events 20 | } 21 | 22 | // AddEvent adds a tracked change to the Entity 23 | func (e *EntityBase) AddEvent(events ...Event) { 24 | e.events = append(e.events, events...) 25 | } 26 | 27 | // ClearEvents resets the tracked change list 28 | func (e *EntityBase) ClearEvents() { 29 | e.events = []Event{} 30 | } 31 | -------------------------------------------------------------------------------- /core/event.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | // Event interface 9 | type Event interface { 10 | EventName() string 11 | } 12 | 13 | // SerializeEvent serializes events with a registered marshaller 14 | func SerializeEvent(v Event) ([]byte, error) { 15 | return marshal(v.EventName(), v) 16 | } 17 | 18 | // DeserializeEvent deserializes the event data using a registered marshaller returning an *Event 19 | func DeserializeEvent(eventName string, data []byte) (Event, error) { 20 | evt, err := unmarshal(eventName, data) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if evt != nil { 26 | if _, ok := evt.(Event); !ok { 27 | return nil, fmt.Errorf("`%s` was registered but not registered as an event", eventName) 28 | } 29 | } 30 | 31 | return evt.(Event), nil 32 | } 33 | 34 | // RegisterEvents registers one or more events with a registered marshaller 35 | // 36 | // Register events using any form desired "&MyEvent{}", "MyEvent{}", "(*MyEvent)(nil)" 37 | // 38 | // Events must be registered after first registering a marshaller you wish to use 39 | func RegisterEvents(events ...Event) { 40 | for _, event := range events { 41 | if v := reflect.ValueOf(event); v.Kind() == reflect.Ptr && v.Pointer() == 0 { 42 | eventName := reflect.Zero(reflect.TypeOf(event).Elem()).Interface().(Event).EventName() 43 | registerType(eventName, event) 44 | } else { 45 | registerType(event.EventName(), event) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/event_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/core/coretest" 9 | ) 10 | 11 | type ( 12 | testEvent struct{ Value string } 13 | unregisteredEvent struct{ Value string } 14 | ) 15 | 16 | func (testEvent) EventName() string { return "core_test.testEvent" } 17 | func (unregisteredEvent) EventName() string { return "core_test.unregisteredEvent" } 18 | 19 | var ( 20 | testEvt = &testEvent{"event"} 21 | unregisteredEvt = &unregisteredEvent{"event"} 22 | ) 23 | 24 | func TestDeserializeEvent(t *testing.T) { 25 | type args struct { 26 | eventName string 27 | data []byte 28 | } 29 | 30 | testMarshaller := coretest.NewTestMarshaller() 31 | core.RegisterDefaultMarshaller(testMarshaller) 32 | core.RegisterEvents(testEvent{}) 33 | core.RegisterCommands(testCommand{}) 34 | 35 | tests := map[string]struct { 36 | args args 37 | want core.Event 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | args: args{ 42 | eventName: testEvent{}.EventName(), 43 | data: getGoldenFileData(t, testEvent{}.EventName()), 44 | }, 45 | want: testEvt, 46 | wantErr: false, 47 | }, 48 | "SuccessEmpty": { 49 | args: args{ 50 | eventName: testEvent{}.EventName(), 51 | data: []byte("{}"), 52 | }, 53 | want: &testEvent{}, 54 | wantErr: false, 55 | }, 56 | "FailureNoData": { 57 | args: args{ 58 | eventName: testEvent{}.EventName(), 59 | }, 60 | want: nil, 61 | wantErr: true, 62 | }, 63 | "FailureWrongType": { 64 | args: args{ 65 | eventName: testCommand{}.CommandName(), 66 | data: []byte("{}"), 67 | }, 68 | want: nil, 69 | wantErr: true, 70 | }, 71 | "FailureUnregistered": { 72 | args: args{ 73 | eventName: unregisteredEvent{}.EventName(), 74 | }, 75 | want: nil, 76 | wantErr: true, 77 | }, 78 | } 79 | for name, tt := range tests { 80 | t.Run(name, func(t *testing.T) { 81 | got, err := core.DeserializeEvent(tt.args.eventName, tt.args.data) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("DeserializeEvent() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | if !reflect.DeepEqual(got, tt.want) { 87 | t.Errorf("DeserializeEvent() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestSerializeEvent(t *testing.T) { 94 | type args struct { 95 | v core.Event 96 | } 97 | 98 | testMarshaller := coretest.NewTestMarshaller() 99 | core.RegisterDefaultMarshaller(testMarshaller) 100 | core.RegisterEvents(testEvent{}) 101 | 102 | tests := map[string]struct { 103 | args args 104 | want []byte 105 | wantErr bool 106 | }{ 107 | "Success": { 108 | args: args{testEvt}, 109 | want: getGoldenFileData(t, testEvent{}.EventName()), 110 | wantErr: false, 111 | }, 112 | "SuccessEmpty": { 113 | args: args{testEvent{}}, 114 | want: []byte(`{"Value":""}`), 115 | wantErr: false, 116 | }, 117 | "FailureUnregistered": { 118 | args: args{unregisteredEvt}, 119 | want: nil, 120 | wantErr: true, 121 | }, 122 | } 123 | for name, tt := range tests { 124 | t.Run(name, func(t *testing.T) { 125 | got, err := core.SerializeEvent(tt.args.v) 126 | if (err != nil) != tt.wantErr { 127 | t.Errorf("SerializeEvent() error = %v, wantErr %v", err, tt.wantErr) 128 | return 129 | } 130 | if !reflect.DeepEqual(got, tt.want) { 131 | t.Errorf("SerializeEvent() got = %v, want %v", string(got), string(tt.want)) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/register_types/register_types.go: -------------------------------------------------------------------------------- 1 | package registertypes 2 | 3 | import ( 4 | "github.com/stackus/edat/msg" 5 | ) 6 | 7 | // RegisterTypes registers internal library types 8 | // 9 | // Marshaller implementors: This should be called automatically after registering a new default marshaller. 10 | // 11 | // Users: There shouldn't be any reason to call this directly. 12 | func RegisterTypes() { 13 | msg.RegisterTypes() 14 | } 15 | -------------------------------------------------------------------------------- /core/registry.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "sync" 7 | ) 8 | 9 | // Marshaller provides marshaling functions and type tracking capabilities 10 | // 11 | // This is how the library avoids requiring boilerplate written to convert each data type to 12 | // and from a marshalled form. 13 | // 14 | // An example marshaller that uses gogoproto with "gogoslick_out" code generation. Adding 15 | // a protobuf based Marshaller will result in significant speed improvements at the 16 | // expense of having to maintain generated code. 17 | // // Define a marshaller 18 | // type MyProtoMarshaller struct{} 19 | // func (MyProtoMarshaller) Marshal(v interface{}) ([]byte, error) { return proto.Marshal(v.(proto.Message))} 20 | // func (MyProtoMarshaller) Unmarshal(data []byte, v interface{}) error { return proto.Unmarshal(data, v.(proto.Message))} 21 | // func (MyProtoMarshaller) GetType(typeName string) reflect.Type { 22 | // t := proto.MessageType(typeName) 23 | // if t != nil { 24 | // return t.Elem() 25 | // } 26 | // return nil 27 | // } 28 | // func (ProtoMarshaller) RegisterType(string, reflect.Type) {} 29 | // 30 | // // Register your marshaller and a function to test for the types it should be given to handle 31 | // core.RegisterMarshaller(MyProtoMarshaller{}, func(i interface{}) bool { 32 | // _, ok := i.(proto.Message) 33 | // return ok 34 | // }) 35 | type Marshaller interface { 36 | Marshal(interface{}) ([]byte, error) 37 | Unmarshal([]byte, interface{}) error 38 | GetType(typeName string) reflect.Type 39 | RegisterType(typeName string, v reflect.Type) 40 | } 41 | 42 | type registeredMarshaller struct { 43 | marshaller Marshaller 44 | affinity func(interface{}) bool 45 | } 46 | 47 | var registry = struct { 48 | defaultMarshaller Marshaller 49 | marshallers []registeredMarshaller 50 | mu sync.Mutex 51 | }{ 52 | marshallers: []registeredMarshaller{}, 53 | mu: sync.Mutex{}, 54 | } 55 | 56 | func registerType(typeName string, v interface{}) { 57 | marshaller := registry.defaultMarshaller 58 | 59 | for _, s := range registry.marshallers { 60 | if s.affinity(v) { 61 | marshaller = s.marshaller 62 | break 63 | } 64 | } 65 | 66 | if marshaller == nil { 67 | panic("no marshallers have been set") 68 | } 69 | 70 | var t reflect.Type 71 | 72 | if value := reflect.ValueOf(v); value.Kind() == reflect.Ptr && value.Pointer() == 0 { 73 | t = reflect.TypeOf(v).Elem() 74 | } else { 75 | t = reflect.TypeOf(v) 76 | 77 | if value.Kind() == reflect.Ptr { 78 | t = t.Elem() 79 | } 80 | } 81 | 82 | marshaller.RegisterType(typeName, t) 83 | } 84 | 85 | func marshal(typeName string, v interface{}) ([]byte, error) { 86 | var t reflect.Type 87 | 88 | marshaller := registry.defaultMarshaller 89 | 90 | if marshaller != nil { 91 | t = marshaller.GetType(typeName) 92 | } 93 | 94 | if marshaller == nil || marshaller.GetType(typeName) == nil { 95 | for _, s := range registry.marshallers { 96 | if t = s.marshaller.GetType(typeName); t != nil { 97 | marshaller = s.marshaller 98 | break 99 | } 100 | } 101 | } 102 | 103 | if marshaller == nil || t == nil { 104 | return nil, fmt.Errorf("`%s` was not registered with any marshaller", typeName) 105 | } 106 | 107 | return marshaller.Marshal(v) 108 | } 109 | 110 | func unmarshal(typeName string, data []byte) (interface{}, error) { 111 | var t reflect.Type 112 | 113 | marshaller := registry.defaultMarshaller 114 | 115 | if marshaller != nil { 116 | t = marshaller.GetType(typeName) 117 | } 118 | 119 | if t == nil { 120 | for _, s := range registry.marshallers { 121 | if t = s.marshaller.GetType(typeName); t != nil { 122 | marshaller = s.marshaller 123 | break 124 | } 125 | } 126 | } 127 | 128 | if marshaller == nil || t == nil { 129 | return nil, fmt.Errorf("`%s` was not registered with any marshaller", typeName) 130 | } 131 | 132 | dst := reflect.New(t).Interface() 133 | 134 | err := marshaller.Unmarshal(data, dst) 135 | return dst, err 136 | } 137 | 138 | // RegisterMarshaller allows applications to register a new optimized marshaller for specific types or situations 139 | func RegisterMarshaller(marshaller Marshaller, affinityFn func(interface{}) bool) { 140 | registerMarshaller(marshaller, affinityFn, false) 141 | } 142 | 143 | // RegisterDefaultMarshaller registers a marshaller to be used when no other marshaller should be used 144 | func RegisterDefaultMarshaller(marshaller Marshaller) { 145 | registerMarshaller(marshaller, nil, true) 146 | } 147 | 148 | func registerMarshaller(marshaller Marshaller, affinityFn func(interface{}) bool, asDefault bool) { 149 | registry.mu.Lock() 150 | defer registry.mu.Unlock() 151 | 152 | if asDefault { 153 | registry.defaultMarshaller = marshaller 154 | return 155 | } 156 | 157 | rm := registeredMarshaller{ 158 | marshaller: marshaller, 159 | affinity: affinityFn, 160 | } 161 | 162 | registry.marshallers = append(registry.marshallers, rm) 163 | } 164 | -------------------------------------------------------------------------------- /core/reply.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | // Reply interface 9 | type Reply interface { 10 | ReplyName() string 11 | } 12 | 13 | // SerializeReply serializes replies with a registered marshaller 14 | func SerializeReply(v Reply) ([]byte, error) { 15 | return marshal(v.ReplyName(), v) 16 | } 17 | 18 | // DeserializeReply deserializes the reply data using a registered marshaller returning a *Reply 19 | func DeserializeReply(replyName string, data []byte) (Reply, error) { 20 | reply, err := unmarshal(replyName, data) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if reply != nil { 26 | if _, ok := reply.(Reply); !ok { 27 | return nil, fmt.Errorf("`%s` was registered but not registered as a reply", replyName) 28 | } 29 | } 30 | 31 | return reply.(Reply), nil 32 | } 33 | 34 | // RegisterReplies registers one or more replies with a registered marshaller 35 | // 36 | // Register replies using any form desired "&MyReply{}", "MyReply{}", "(*MyReply)(nil)" 37 | // 38 | // Replies must be registered after first registering a marshaller you wish to use 39 | func RegisterReplies(replies ...Reply) { 40 | for _, reply := range replies { 41 | if v := reflect.ValueOf(reply); v.Kind() == reflect.Ptr && v.Pointer() == 0 { 42 | replyName := reflect.Zero(reflect.TypeOf(reply).Elem()).Interface().(Reply).ReplyName() 43 | registerType(replyName, reply) 44 | } else { 45 | registerType(reply.ReplyName(), reply) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/reply_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/core/coretest" 9 | ) 10 | 11 | type ( 12 | testReply struct{ Value string } 13 | unregisteredReply struct{ Value string } 14 | ) 15 | 16 | func (testReply) ReplyName() string { return "core_test.testReply" } 17 | func (unregisteredReply) ReplyName() string { return "core_test.unregisteredReply" } 18 | 19 | var ( 20 | testRp = &testReply{"reply"} 21 | unregisteredRp = &unregisteredReply{"reply"} 22 | ) 23 | 24 | func TestDeserializeReply(t *testing.T) { 25 | type args struct { 26 | replyName string 27 | data []byte 28 | } 29 | 30 | testMarshaller := coretest.NewTestMarshaller() 31 | core.RegisterDefaultMarshaller(testMarshaller) 32 | core.RegisterReplies(testReply{}) 33 | core.RegisterEvents(testEvent{}) 34 | 35 | tests := map[string]struct { 36 | args args 37 | want core.Reply 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | args: args{ 42 | replyName: testReply{}.ReplyName(), 43 | data: getGoldenFileData(t, testReply{}.ReplyName()), 44 | }, 45 | want: testRp, 46 | wantErr: false, 47 | }, 48 | "SuccessEmpty": { 49 | args: args{ 50 | replyName: testReply{}.ReplyName(), 51 | data: []byte("{}"), 52 | }, 53 | want: &testReply{}, 54 | wantErr: false, 55 | }, 56 | "FailureNoData": { 57 | args: args{ 58 | replyName: testReply{}.ReplyName(), 59 | }, 60 | want: nil, 61 | wantErr: true, 62 | }, 63 | "FailureWrongType": { 64 | args: args{ 65 | replyName: testEvent{}.EventName(), 66 | data: []byte("{}"), 67 | }, 68 | want: nil, 69 | wantErr: true, 70 | }, 71 | "FailureUnregistered": { 72 | args: args{ 73 | replyName: unregisteredReply{}.ReplyName(), 74 | }, 75 | want: nil, 76 | wantErr: true, 77 | }, 78 | } 79 | for name, tt := range tests { 80 | t.Run(name, func(t *testing.T) { 81 | got, err := core.DeserializeReply(tt.args.replyName, tt.args.data) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("DeserializeReply() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | if !reflect.DeepEqual(got, tt.want) { 87 | t.Errorf("DeserializeReply() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestSerializeReply(t *testing.T) { 94 | type args struct { 95 | v core.Reply 96 | } 97 | 98 | testMarshaller := coretest.NewTestMarshaller() 99 | core.RegisterDefaultMarshaller(testMarshaller) 100 | core.RegisterReplies(testReply{}) 101 | 102 | tests := map[string]struct { 103 | args args 104 | want []byte 105 | wantErr bool 106 | }{ 107 | "Success": { 108 | args: args{testRp}, 109 | want: getGoldenFileData(t, testReply{}.ReplyName()), 110 | wantErr: false, 111 | }, 112 | "SuccessEmpty": { 113 | args: args{testReply{}}, 114 | want: []byte(`{"Value":""}`), 115 | wantErr: false, 116 | }, 117 | "FailureUnregistered": { 118 | args: args{unregisteredRp}, 119 | want: nil, 120 | wantErr: true, 121 | }, 122 | } 123 | for name, tt := range tests { 124 | t.Run(name, func(t *testing.T) { 125 | got, err := core.SerializeReply(tt.args.v) 126 | if (err != nil) != tt.wantErr { 127 | t.Errorf("SerializeReply() error = %v, wantErr %v", err, tt.wantErr) 128 | return 129 | } 130 | if !reflect.DeepEqual(got, tt.want) { 131 | t.Errorf("SerializeReply() got = %v, want %v", string(got), string(tt.want)) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/saga_data.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | // SagaData interface 9 | type SagaData interface { 10 | SagaDataName() string 11 | } 12 | 13 | // SerializeSagaData serializes saga data with a registered marshaller 14 | func SerializeSagaData(v SagaData) ([]byte, error) { 15 | return marshal(v.SagaDataName(), v) 16 | } 17 | 18 | // DeserializeSagaData deserializes the saga data data using a registered marshaller returning a *SagaData 19 | func DeserializeSagaData(sagaDataName string, data []byte) (SagaData, error) { 20 | sagaData, err := unmarshal(sagaDataName, data) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if sagaData != nil { 26 | if _, ok := sagaData.(SagaData); !ok { 27 | return nil, fmt.Errorf("`%s` was registered but not registered as a saga data", sagaDataName) 28 | } 29 | } 30 | 31 | return sagaData.(SagaData), nil 32 | } 33 | 34 | // RegisterSagaData registers one or more saga data with a registered marshaller 35 | // 36 | // Register saga data using any form desired "&MySagaData{}", "MySagaData{}", "(*MySagaData)(nil)" 37 | // 38 | // SagaData must be registered after first registering a marshaller you wish to use 39 | func RegisterSagaData(sagaDatas ...SagaData) { 40 | for _, sagaData := range sagaDatas { 41 | if v := reflect.ValueOf(sagaData); v.Kind() == reflect.Ptr && v.Pointer() == 0 { 42 | sagaDataName := reflect.Zero(reflect.TypeOf(sagaData).Elem()).Interface().(SagaData).SagaDataName() 43 | registerType(sagaDataName, sagaData) 44 | } else { 45 | registerType(sagaData.SagaDataName(), sagaData) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/saga_data_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/core/coretest" 9 | ) 10 | 11 | type ( 12 | testSagaData struct{ Value string } 13 | unregisteredSagaData struct{ Value string } 14 | ) 15 | 16 | func (testSagaData) SagaDataName() string { return "core_test.testSagaData" } 17 | func (unregisteredSagaData) SagaDataName() string { return "core_test.unregisteredSagaData" } 18 | 19 | var ( 20 | testSd = &testSagaData{"sagaData"} 21 | unregisteredSd = &unregisteredSagaData{"sagaData"} 22 | ) 23 | 24 | func TestDeserializeSagaData(t *testing.T) { 25 | type args struct { 26 | sagaDataName string 27 | data []byte 28 | } 29 | 30 | testMarshaller := coretest.NewTestMarshaller() 31 | core.RegisterDefaultMarshaller(testMarshaller) 32 | core.RegisterSagaData(testSagaData{}) 33 | core.RegisterEvents(testEvent{}) 34 | 35 | tests := map[string]struct { 36 | args args 37 | want core.SagaData 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | args: args{ 42 | sagaDataName: testSagaData{}.SagaDataName(), 43 | data: getGoldenFileData(t, testSagaData{}.SagaDataName()), 44 | }, 45 | want: testSd, 46 | wantErr: false, 47 | }, 48 | "SuccessEmpty": { 49 | args: args{ 50 | sagaDataName: testSagaData{}.SagaDataName(), 51 | data: []byte("{}"), 52 | }, 53 | want: &testSagaData{}, 54 | wantErr: false, 55 | }, 56 | "FailureNoData": { 57 | args: args{ 58 | sagaDataName: testSagaData{}.SagaDataName(), 59 | }, 60 | want: nil, 61 | wantErr: true, 62 | }, 63 | "FailureWrongType": { 64 | args: args{ 65 | sagaDataName: testEvent{}.EventName(), 66 | data: []byte("{}"), 67 | }, 68 | want: nil, 69 | wantErr: true, 70 | }, 71 | "FailureUnregistered": { 72 | args: args{ 73 | sagaDataName: unregisteredSagaData{}.SagaDataName(), 74 | }, 75 | want: nil, 76 | wantErr: true, 77 | }, 78 | } 79 | for name, tt := range tests { 80 | t.Run(name, func(t *testing.T) { 81 | got, err := core.DeserializeSagaData(tt.args.sagaDataName, tt.args.data) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("DeserializeSagaData() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | if !reflect.DeepEqual(got, tt.want) { 87 | t.Errorf("DeserializeSagaData() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestSerializeSagaData(t *testing.T) { 94 | type args struct { 95 | v core.SagaData 96 | } 97 | 98 | testMarshaller := coretest.NewTestMarshaller() 99 | core.RegisterDefaultMarshaller(testMarshaller) 100 | core.RegisterSagaData(testSagaData{}) 101 | 102 | tests := map[string]struct { 103 | args args 104 | want []byte 105 | wantErr bool 106 | }{ 107 | "Success": { 108 | args: args{testSd}, 109 | want: getGoldenFileData(t, testSagaData{}.SagaDataName()), 110 | wantErr: false, 111 | }, 112 | "SuccessEmpty": { 113 | args: args{testSagaData{}}, 114 | want: []byte(`{"Value":""}`), 115 | wantErr: false, 116 | }, 117 | "FailureUnregistered": { 118 | args: args{unregisteredSd}, 119 | want: nil, 120 | wantErr: true, 121 | }, 122 | } 123 | for name, tt := range tests { 124 | t.Run(name, func(t *testing.T) { 125 | got, err := core.SerializeSagaData(tt.args.v) 126 | if (err != nil) != tt.wantErr { 127 | t.Errorf("SerializeSagaData() error = %v, wantErr %v", err, tt.wantErr) 128 | return 129 | } 130 | if !reflect.DeepEqual(got, tt.want) { 131 | t.Errorf("SerializeSagaData() got = %v, want %v", string(got), string(tt.want)) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/snapshot.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | // Snapshot interface 9 | type Snapshot interface { 10 | SnapshotName() string 11 | } 12 | 13 | // SerializeSnapshot serializes snapshots with a registered marshaller 14 | func SerializeSnapshot(v Snapshot) ([]byte, error) { 15 | return marshal(v.SnapshotName(), v) 16 | } 17 | 18 | // DeserializeSnapshot deserializes the snapshot data using a registered marshaller returning a *Snapshot 19 | func DeserializeSnapshot(snapshotName string, data []byte) (Snapshot, error) { 20 | snapshot, err := unmarshal(snapshotName, data) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if snapshot != nil { 26 | if _, ok := snapshot.(Snapshot); !ok { 27 | return nil, fmt.Errorf("`%s` was registered but not registered as a snapshot", snapshotName) 28 | } 29 | } 30 | 31 | return snapshot.(Snapshot), nil 32 | } 33 | 34 | // RegisterSnapshots registers one or more snapshots with a registered marshaller 35 | // 36 | // Register snapshots using any form desired "&MySnapshot{}", "MySnapshot{}", "(*MySnapshot)(nil)" 37 | // 38 | // Snapshots must be registered after first registering a marshaller you wish to use 39 | func RegisterSnapshots(snapshots ...Snapshot) { 40 | for _, snapshot := range snapshots { 41 | if v := reflect.ValueOf(snapshot); v.Kind() == reflect.Ptr && v.Pointer() == 0 { 42 | snapshotName := reflect.Zero(reflect.TypeOf(snapshot).Elem()).Interface().(Snapshot).SnapshotName() 43 | registerType(snapshotName, snapshot) 44 | } else { 45 | registerType(snapshot.SnapshotName(), snapshot) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/snapshot_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/core/coretest" 9 | ) 10 | 11 | type ( 12 | testSnapshot struct{ Value string } 13 | unregisteredSnapshot struct{ Value string } 14 | ) 15 | 16 | func (testSnapshot) SnapshotName() string { return "core_test.testSnapshot" } 17 | func (unregisteredSnapshot) SnapshotName() string { return "core_test.unregisteredSnapshot" } 18 | 19 | var ( 20 | testSs = &testSnapshot{"snapshot"} 21 | unregisteredSs = &unregisteredSnapshot{"snapshot"} 22 | ) 23 | 24 | func TestDeserializeSnapshot(t *testing.T) { 25 | type args struct { 26 | snapshotName string 27 | data []byte 28 | } 29 | 30 | testMarshaller := coretest.NewTestMarshaller() 31 | core.RegisterDefaultMarshaller(testMarshaller) 32 | core.RegisterSnapshots(testSnapshot{}) 33 | core.RegisterEvents(testEvent{}) 34 | 35 | tests := map[string]struct { 36 | args args 37 | want core.Snapshot 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | args: args{ 42 | snapshotName: testSnapshot{}.SnapshotName(), 43 | data: getGoldenFileData(t, testSnapshot{}.SnapshotName()), 44 | }, 45 | want: testSs, 46 | wantErr: false, 47 | }, 48 | "SuccessEmpty": { 49 | args: args{ 50 | snapshotName: testSnapshot{}.SnapshotName(), 51 | data: []byte("{}"), 52 | }, 53 | want: &testSnapshot{}, 54 | wantErr: false, 55 | }, 56 | "FailureNoData": { 57 | args: args{ 58 | snapshotName: testSnapshot{}.SnapshotName(), 59 | }, 60 | want: nil, 61 | wantErr: true, 62 | }, 63 | "FailureWrongType": { 64 | args: args{ 65 | snapshotName: testEvent{}.EventName(), 66 | data: []byte("{}"), 67 | }, 68 | want: nil, 69 | wantErr: true, 70 | }, 71 | "FailureUnregistered": { 72 | args: args{ 73 | snapshotName: unregisteredSnapshot{}.SnapshotName(), 74 | }, 75 | want: nil, 76 | wantErr: true, 77 | }, 78 | } 79 | for name, tt := range tests { 80 | t.Run(name, func(t *testing.T) { 81 | got, err := core.DeserializeSnapshot(tt.args.snapshotName, tt.args.data) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("DeserializeSnapshot() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | if !reflect.DeepEqual(got, tt.want) { 87 | t.Errorf("DeserializeSnapshot() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestSerializeSnapshot(t *testing.T) { 94 | type args struct { 95 | v core.Snapshot 96 | } 97 | 98 | testMarshaller := coretest.NewTestMarshaller() 99 | core.RegisterDefaultMarshaller(testMarshaller) 100 | core.RegisterSnapshots(testSnapshot{}) 101 | 102 | tests := map[string]struct { 103 | args args 104 | want []byte 105 | wantErr bool 106 | }{ 107 | "Success": { 108 | args: args{testSs}, 109 | want: getGoldenFileData(t, testSnapshot{}.SnapshotName()), 110 | wantErr: false, 111 | }, 112 | "SuccessEmpty": { 113 | args: args{testSnapshot{}}, 114 | want: []byte(`{"Value":""}`), 115 | wantErr: false, 116 | }, 117 | "FailureUnregistered": { 118 | args: args{unregisteredSs}, 119 | want: nil, 120 | wantErr: true, 121 | }, 122 | } 123 | for name, tt := range tests { 124 | t.Run(name, func(t *testing.T) { 125 | got, err := core.SerializeSnapshot(tt.args.v) 126 | if (err != nil) != tt.wantErr { 127 | t.Errorf("SerializeSnapshot() error = %v, wantErr %v", err, tt.wantErr) 128 | return 129 | } 130 | if !reflect.DeepEqual(got, tt.want) { 131 | t.Errorf("SerializeSnapshot() got = %v, want %v", string(got), string(tt.want)) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/testdata/core_test.testCommand.golden: -------------------------------------------------------------------------------- 1 | {"Value":"command"} -------------------------------------------------------------------------------- /core/testdata/core_test.testEvent.golden: -------------------------------------------------------------------------------- 1 | {"Value":"event"} -------------------------------------------------------------------------------- /core/testdata/core_test.testReply.golden: -------------------------------------------------------------------------------- 1 | {"Value":"reply"} -------------------------------------------------------------------------------- /core/testdata/core_test.testSagaData.golden: -------------------------------------------------------------------------------- 1 | {"Value":"sagaData"} -------------------------------------------------------------------------------- /core/testdata/core_test.testSnapshot.golden: -------------------------------------------------------------------------------- 1 | {"Value":"snapshot"} -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package edat provides implementations supporting event driven architectures. 2 | /* 3 | TODO documentation for core 4 | TODO documentation for es 5 | TODO documentation for msg 6 | TODO documentation for saga 7 | TODO documentation for outbox 8 | 9 | TODO other documentation 10 | */ 11 | package edat 12 | -------------------------------------------------------------------------------- /es/aggregate.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // Aggregate is a domain object that will be used at the base of other domain objects 8 | type Aggregate interface { 9 | core.Entity 10 | setID(id string) 11 | ProcessCommand(command core.Command) error 12 | ApplyEvent(event core.Event) error 13 | ApplySnapshot(snapshot core.Snapshot) error 14 | ToSnapshot() (core.Snapshot, error) 15 | } 16 | 17 | // AggregateBase provides aggregates a base to build on 18 | type AggregateBase struct { 19 | core.EntityBase 20 | id string 21 | } 22 | 23 | // ID returns to the immutable ID of the aggregate 24 | func (a AggregateBase) ID() string { 25 | return a.id 26 | } 27 | 28 | // setID is used internally by the AggregateRoot to apply IDs when loading existing Aggregates 29 | // nolint unused 30 | func (a *AggregateBase) setID(id string) { 31 | a.id = id 32 | } 33 | -------------------------------------------------------------------------------- /es/aggregate_root.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/stackus/edat/core" 9 | ) 10 | 11 | const aggregateNeverCommitted = 0 12 | 13 | // ErrPendingChanges is the error returned when a second command is applied to an aggregate without clearing changes 14 | var ErrPendingChanges = fmt.Errorf("cannot process command while pending changes exist") 15 | 16 | // AggregateRoot is the base for Aggregates 17 | type AggregateRoot struct { 18 | aggregate Aggregate 19 | version int 20 | } 21 | 22 | // NewAggregateRoot constructor for *AggregateRoot 23 | func NewAggregateRoot(aggregate Aggregate, options ...AggregateRootOption) *AggregateRoot { 24 | r := &AggregateRoot{ 25 | aggregate: aggregate, 26 | version: aggregateNeverCommitted, 27 | } 28 | 29 | for _, option := range options { 30 | option(r) 31 | } 32 | 33 | if r.aggregate.ID() == "" { 34 | r.aggregate.setID(uuid.New().String()) 35 | } 36 | 37 | return r 38 | } 39 | 40 | // ID returns the ID for the root aggregate 41 | func (r AggregateRoot) ID() string { 42 | return r.aggregate.ID() 43 | } 44 | 45 | // AggregateID returns the ID for the root aggregate 46 | func (r AggregateRoot) AggregateID() string { 47 | return r.aggregate.ID() 48 | } 49 | 50 | // EntityName returns the Name of the root aggregate 51 | func (r AggregateRoot) EntityName() string { 52 | return r.aggregate.EntityName() 53 | } 54 | 55 | // AggregateName returns the Name of the root aggregate 56 | func (r AggregateRoot) AggregateName() string { 57 | return r.aggregate.EntityName() 58 | } 59 | 60 | // Aggregate returns the aggregate that resides at the root 61 | func (r AggregateRoot) Aggregate() Aggregate { 62 | return r.aggregate 63 | } 64 | 65 | // PendingVersion is the version of the aggregate taking into account pending events 66 | func (r AggregateRoot) PendingVersion() int { 67 | return r.version + len(r.aggregate.Events()) 68 | } 69 | 70 | // Version is the version of the aggregate as it was created or loaded 71 | func (r AggregateRoot) Version() int { 72 | return r.version 73 | } 74 | 75 | // ProcessCommand runs the command and records the changes as pending events or returns an error 76 | func (r *AggregateRoot) ProcessCommand(command core.Command) error { 77 | if len(r.aggregate.Events()) != 0 { 78 | return ErrPendingChanges 79 | } 80 | 81 | err := r.aggregate.ProcessCommand(command) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | for _, event := range r.aggregate.Events() { 87 | aErr := r.aggregate.ApplyEvent(event) 88 | if aErr != nil { 89 | return aErr 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // Events returns the list of pending events 97 | func (r AggregateRoot) Events() []core.Event { 98 | return r.aggregate.Events() 99 | } 100 | 101 | // AddEvent stores entity events on the aggregate 102 | func (r *AggregateRoot) AddEvent(events ...core.Event) { 103 | r.aggregate.AddEvent(events...) 104 | } 105 | 106 | // ClearEvents clears any pending events without committing them 107 | func (r *AggregateRoot) ClearEvents() { 108 | r.aggregate.ClearEvents() 109 | } 110 | 111 | // CommitEvents clears any pending events and updates the last committed version value 112 | func (r *AggregateRoot) CommitEvents() { 113 | r.version += len(r.aggregate.Events()) 114 | r.aggregate.ClearEvents() 115 | } 116 | 117 | // LoadEvent is used to rerun events essentially left folding over the aggregate state 118 | func (r *AggregateRoot) LoadEvent(events ...core.Event) error { 119 | for _, event := range events { 120 | err := r.aggregate.ApplyEvent(event) 121 | if err != nil { 122 | return err 123 | } 124 | } 125 | 126 | r.version += len(events) 127 | 128 | return nil 129 | } 130 | 131 | // LoadSnapshot is used to apply a snapshot to the aggregate to save having to rerun all events 132 | func (r *AggregateRoot) LoadSnapshot(snapshot core.Snapshot, version int) error { 133 | err := r.aggregate.ApplySnapshot(snapshot) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | r.version = version 139 | 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /es/aggregate_root_options.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | // AggregateRootOption options for AggregateRoots 4 | type AggregateRootOption func(r *AggregateRoot) 5 | 6 | // WithAggregateRootID is an option to set the ID of the AggregateRoot 7 | func WithAggregateRootID(aggregateID string) AggregateRootOption { 8 | return func(r *AggregateRoot) { 9 | r.aggregate.setID(aggregateID) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /es/aggregate_root_repository.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/log" 9 | ) 10 | 11 | // AggregateRepository interface 12 | type AggregateRepository interface { 13 | Load(ctx context.Context, aggregateID string) (*AggregateRoot, error) 14 | Save(ctx context.Context, command core.Command, options ...AggregateRootOption) (*AggregateRoot, error) 15 | Update(ctx context.Context, aggregateID string, command core.Command, options ...AggregateRootOption) (*AggregateRoot, error) 16 | } 17 | 18 | // AggregateRootRepository uses stores to load and save the changes to aggregates as events 19 | type AggregateRootRepository struct { 20 | constructor func() Aggregate 21 | store AggregateRootStore 22 | logger log.Logger 23 | } 24 | 25 | // AggregateRootStoreMiddleware interface for embedding stores 26 | type AggregateRootStoreMiddleware func(store AggregateRootStore) AggregateRootStore 27 | 28 | // ErrAggregateNotFound is returned when no root was found for a given aggregate id 29 | var ErrAggregateNotFound = errors.New("aggregate not found") 30 | 31 | // ErrAggregateVersionMismatch should be returned by stores when new events cannot be appended due to version conflicts 32 | var ErrAggregateVersionMismatch = errors.New("aggregate version mismatch") 33 | 34 | // NewAggregateRootRepository constructs a new AggregateRootRepository 35 | func NewAggregateRootRepository(constructor func() Aggregate, store AggregateRootStore) *AggregateRootRepository { 36 | r := &AggregateRootRepository{ 37 | constructor: constructor, 38 | store: store, 39 | logger: log.DefaultLogger, 40 | } 41 | 42 | r.logger.Trace("es.AggregateRootRepository constructed") 43 | 44 | return r 45 | } 46 | 47 | // Load finds aggregates in the provided store 48 | func (r *AggregateRootRepository) Load(ctx context.Context, aggregateID string) (*AggregateRoot, error) { 49 | root := r.root(WithAggregateRootID(aggregateID)) 50 | 51 | err := r.store.Load(ctx, root) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if root.version == aggregateNeverCommitted { 57 | return nil, ErrAggregateNotFound 58 | } 59 | 60 | return root, r.store.Load(ctx, root) 61 | } 62 | 63 | // Save applies the given command to a new aggregate and persists it into the store 64 | func (r *AggregateRootRepository) Save(ctx context.Context, command core.Command, options ...AggregateRootOption) (*AggregateRoot, error) { 65 | root := r.root(options...) 66 | 67 | return root, r.save(ctx, command, root) 68 | } 69 | 70 | // Update locates an existing aggregate, applies the commands and persists the result into the store 71 | func (r *AggregateRootRepository) Update(ctx context.Context, aggregateID string, command core.Command, options ...AggregateRootOption) (*AggregateRoot, error) { 72 | root := r.root(append(options, WithAggregateRootID(aggregateID))...) 73 | 74 | err := r.store.Load(ctx, root) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | // require aggregates to exist to be "Updated" 80 | if root.version == aggregateNeverCommitted { 81 | return nil, ErrAggregateNotFound 82 | } 83 | 84 | return root, r.save(ctx, command, root) 85 | } 86 | 87 | func (r *AggregateRootRepository) root(options ...AggregateRootOption) *AggregateRoot { 88 | return NewAggregateRoot(r.constructor(), options...) 89 | } 90 | 91 | func (r *AggregateRootRepository) save(ctx context.Context, command core.Command, root *AggregateRoot) error { 92 | err := root.ProcessCommand(command) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if root.PendingVersion() == root.Version() { 98 | return nil 99 | } 100 | 101 | err = r.store.Save(ctx, root) 102 | if err != nil { 103 | r.logger.Error("error saving aggregate root", log.Error(err)) 104 | return err 105 | } 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /es/aggregate_root_store.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // AggregateRootStore is the interface that infrastructures should implement to be used in AggregateRootRepositories 8 | type AggregateRootStore interface { 9 | Load(ctx context.Context, aggregate *AggregateRoot) error 10 | Save(ctx context.Context, aggregate *AggregateRoot) error 11 | } 12 | -------------------------------------------------------------------------------- /es/snapshot_strategies.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | // DefaultSnapshotStrategy is a strategy that triggers snapshots every 10 changes 4 | var DefaultSnapshotStrategy = NewMaxChangesSnapshotStrategy(10) 5 | 6 | // SnapshotStrategy interface 7 | type SnapshotStrategy interface { 8 | ShouldSnapshot(aggregate *AggregateRoot) bool 9 | } 10 | 11 | type maxChangesSnapshotStrategy struct { 12 | maxChanges int 13 | } 14 | 15 | // NewMaxChangesSnapshotStrategy constructs a new SnapshotStrategy with "max changes" rules 16 | func NewMaxChangesSnapshotStrategy(maxChanges int) SnapshotStrategy { 17 | return &maxChangesSnapshotStrategy{maxChanges: maxChanges} 18 | } 19 | 20 | // ShouldSnapshot implements es.SnapshotStrategy.ShouldSnapshot 21 | func (s *maxChangesSnapshotStrategy) ShouldSnapshot(aggregate *AggregateRoot) bool { 22 | return aggregate.PendingVersion() >= s.maxChanges && ((len(aggregate.Events()) >= s.maxChanges) || 23 | (aggregate.PendingVersion()%s.maxChanges < len(aggregate.Events())) || 24 | (aggregate.PendingVersion()%s.maxChanges == 0)) 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stackus/edat 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/google/uuid v1.1.4 7 | github.com/stretchr/testify v1.7.0 8 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a 9 | google.golang.org/grpc v1.38.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 4 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 5 | github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 6 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 9 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 10 | github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= 11 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 12 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 13 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 14 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 15 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 16 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 17 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 18 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 19 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 20 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 21 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 22 | github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= 23 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 24 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 25 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 26 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 27 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 28 | github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= 29 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 30 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 31 | github.com/google/uuid v1.1.4 h1:0ecGp3skIrHWPNGPJDaBIghfA6Sp7Ruo2Io8eLKzWm0= 32 | github.com/google/uuid v1.1.4/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 36 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 39 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 40 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 41 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 42 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 43 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 44 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 45 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 46 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 47 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 48 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 49 | golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= 50 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 51 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 52 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 54 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 55 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= 56 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 57 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 58 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 59 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 60 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 61 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 62 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 63 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 64 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 65 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 66 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 67 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 68 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 69 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 70 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 71 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 72 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= 73 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 74 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 75 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 76 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 77 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 78 | google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= 79 | google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= 80 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 81 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 82 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 83 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 84 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 85 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 86 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 87 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 88 | google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= 89 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 93 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 94 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 96 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 97 | -------------------------------------------------------------------------------- /grpc/context.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/metadata" 9 | 10 | "github.com/stackus/edat/core" 11 | ) 12 | 13 | // Request tracking 14 | const ( 15 | requestIDKey = "requestID" 16 | correlationIDKey = "correlationID" 17 | causationIDKey = "causationID" 18 | ) 19 | 20 | type clientStreamWrapper struct { 21 | grpc.ClientStream 22 | } 23 | 24 | func (s clientStreamWrapper) Context() context.Context { 25 | ctx := s.ClientStream.Context() 26 | 27 | md := metadata.New(map[string]string{ 28 | requestIDKey: core.GetRequestID(ctx), 29 | correlationIDKey: core.GetCorrelationID(ctx), 30 | causationIDKey: core.GetCausationID(ctx), 31 | }) 32 | 33 | return metadata.NewOutgoingContext(ctx, md) 34 | } 35 | 36 | type serverStreamWrapper struct { 37 | grpc.ServerStream 38 | } 39 | 40 | func (s serverStreamWrapper) Context() context.Context { 41 | ctx := s.ServerStream.Context() 42 | 43 | requestID := uuid.New().String() 44 | correlationID := requestID 45 | causationID := requestID 46 | 47 | md, _ := metadata.FromIncomingContext(ctx) 48 | vals := md.Get(requestIDKey) 49 | if len(vals) > 0 { 50 | requestID = vals[0] 51 | } 52 | 53 | vals = md.Get(correlationIDKey) 54 | if len(vals) > 0 { 55 | correlationID = vals[0] 56 | } 57 | 58 | vals = md.Get(causationIDKey) 59 | if len(vals) > 0 { 60 | causationID = vals[0] 61 | } 62 | 63 | return core.SetRequestContext(ctx, requestID, correlationID, causationID) 64 | } 65 | 66 | // Unary 67 | 68 | func RequestContextUnaryServerInterceptor(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { 69 | requestID := uuid.New().String() 70 | correlationID := requestID 71 | causationID := requestID 72 | 73 | md, _ := metadata.FromIncomingContext(ctx) 74 | vals := md.Get(requestIDKey) 75 | if len(vals) > 0 { 76 | requestID = vals[0] 77 | } 78 | 79 | vals = md.Get(correlationIDKey) 80 | if len(vals) > 0 { 81 | correlationID = vals[0] 82 | } 83 | 84 | vals = md.Get(causationIDKey) 85 | if len(vals) > 0 { 86 | causationID = vals[0] 87 | } 88 | 89 | return handler(core.SetRequestContext(ctx, requestID, correlationID, causationID), req) 90 | } 91 | 92 | func RequestContextUnaryClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { 93 | // RPC calls are request boundaries 94 | requestID := uuid.New().String() 95 | correlationID := core.GetCorrelationID(ctx) 96 | if correlationID == "" { 97 | correlationID = requestID 98 | } 99 | causationID := core.GetRequestID(ctx) 100 | if causationID == "" { 101 | causationID = requestID 102 | } 103 | md := metadata.New(map[string]string{ 104 | requestIDKey: requestID, 105 | correlationIDKey: correlationID, 106 | causationIDKey: causationID, 107 | }) 108 | 109 | return invoker(metadata.NewOutgoingContext(ctx, md), method, req, reply, cc, opts...) 110 | } 111 | 112 | // Stream 113 | 114 | func RequestContextStreamServerInterceptor(srv interface{}, ss grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error { 115 | return handler(srv, serverStreamWrapper{ss}) 116 | } 117 | 118 | func RequestContextStreamClientInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { 119 | stream, err := streamer(ctx, desc, cc, method, opts...) 120 | if err != nil { 121 | return nil, err 122 | } 123 | return clientStreamWrapper{stream}, nil 124 | } 125 | -------------------------------------------------------------------------------- /http/context.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/google/uuid" 8 | 9 | "github.com/stackus/edat/core" 10 | ) 11 | 12 | // Request tracking 13 | const ( 14 | RequestIDHeader = "X-Request-Id" 15 | CorrelationIDHeader = "X-Correlation-Id" 16 | CausationIDHeader = "X-Causation-Id" 17 | ) 18 | 19 | // RequestContext is an http.Handler middleware that sets the id, correlation, and causation ids into context 20 | func RequestContext(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | requestID := r.Header.Get(RequestIDHeader) 23 | if requestID == "" { 24 | requestID = uuid.New().String() 25 | } 26 | correlationID := r.Header.Get(CorrelationIDHeader) 27 | if correlationID == "" { 28 | correlationID = requestID 29 | } 30 | causationID := r.Header.Get(CausationIDHeader) 31 | if causationID == "" { 32 | causationID = requestID 33 | } 34 | 35 | ctx := core.SetRequestContext(r.Context(), requestID, correlationID, causationID) 36 | 37 | w.Header().Set(RequestIDHeader, core.GetRequestID(ctx)) 38 | next.ServeHTTP(w, r.WithContext(ctx)) 39 | }) 40 | } 41 | 42 | // SetResponseHeaders puts the id, correlation, and causation ids into the outgoing http response 43 | func SetResponseHeaders(ctx context.Context, w http.ResponseWriter) { 44 | w.Header().Set(RequestIDHeader, core.GetRequestID(ctx)) 45 | w.Header().Set(CorrelationIDHeader, core.GetCorrelationID(ctx)) 46 | w.Header().Set(CausationIDHeader, core.GetCausationID(ctx)) 47 | } 48 | -------------------------------------------------------------------------------- /inmem/consumer.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/stackus/edat/log" 8 | "github.com/stackus/edat/msg" 9 | ) 10 | 11 | var channels = sync.Map{} 12 | 13 | // Consumer implements msg.Consumer 14 | type Consumer struct { 15 | logger log.Logger 16 | } 17 | 18 | var _ msg.Consumer = (*Consumer)(nil) 19 | 20 | // NewConsumer constructs a new Consumer 21 | func NewConsumer(options ...ConsumerOption) *Consumer { 22 | c := &Consumer{ 23 | logger: log.DefaultLogger, 24 | } 25 | 26 | for _, option := range options { 27 | option(c) 28 | } 29 | 30 | return c 31 | } 32 | 33 | // Listen implements msg.Consumer.Listen 34 | func (c *Consumer) Listen(ctx context.Context, channel string, consumer msg.ReceiveMessageFunc) error { 35 | result, _ := channels.LoadOrStore(channel, make(chan msg.Message)) 36 | 37 | messages := result.(chan msg.Message) 38 | 39 | for { 40 | select { 41 | case message, ok := <-messages: 42 | if !ok { 43 | return nil 44 | } 45 | err := consumer(ctx, message) 46 | if err != nil { 47 | c.logger.Error("error consuming message", log.Error(err)) 48 | } 49 | case <-ctx.Done(): 50 | return nil 51 | } 52 | } 53 | } 54 | 55 | // Close implements msg.Consumer.Close 56 | func (c *Consumer) Close(context.Context) error { 57 | channels.Range(func(key, value interface{}) bool { 58 | messages := value.(chan msg.Message) 59 | close(messages) 60 | 61 | c.logger.Trace("closed channel", log.String("Channel", key.(string))) 62 | 63 | return true 64 | }) 65 | 66 | c.logger.Trace("closing message source") 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /inmem/consumer_options.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // ConsumerOption options for Consumer 8 | type ConsumerOption func(*Consumer) 9 | 10 | // WithConsumerLogger sets the log.Logger for Consumer 11 | func WithConsumerLogger(logger log.Logger) ConsumerOption { 12 | return func(consumer *Consumer) { 13 | consumer.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /inmem/event_store.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | 9 | "github.com/stackus/edat/core" 10 | "github.com/stackus/edat/es" 11 | ) 12 | 13 | // EventStore implements es.AggregateRootStore 14 | type EventStore struct { 15 | events map[string][]eventMsg 16 | mu sync.Mutex 17 | } 18 | 19 | type eventMsg struct { 20 | eventName string 21 | event json.RawMessage 22 | } 23 | 24 | var _ es.AggregateRootStore = (*EventStore)(nil) 25 | 26 | // NewEventStore constructs a new EventStore 27 | func NewEventStore(options ...EventStoreOption) *EventStore { 28 | s := &EventStore{ 29 | events: make(map[string][]eventMsg), 30 | mu: sync.Mutex{}, 31 | } 32 | 33 | for _, option := range options { 34 | option(s) 35 | } 36 | 37 | return s 38 | } 39 | 40 | // Load implements es.AggregateRootStore.Load 41 | func (s *EventStore) Load(_ context.Context, root *es.AggregateRoot) error { 42 | // just lock it all 43 | s.mu.Lock() 44 | defer s.mu.Unlock() 45 | 46 | name := root.AggregateName() 47 | id := root.AggregateID() 48 | version := root.PendingVersion() 49 | 50 | if messages, exists := s.events[s.streamID(name, id)]; exists { 51 | if len(messages) < version { 52 | return nil 53 | } 54 | 55 | for _, message := range messages[version:] { 56 | event, err := core.DeserializeEvent(message.eventName, message.event) 57 | if err != nil { 58 | return err 59 | } 60 | err = root.LoadEvent(event) 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // Save implements es.AggregateRootStore.Save 71 | func (s *EventStore) Save(_ context.Context, root *es.AggregateRoot) error { 72 | // just lock it all 73 | s.mu.Lock() 74 | defer s.mu.Unlock() 75 | 76 | name := root.AggregateName() 77 | id := root.AggregateID() 78 | version := root.Version() 79 | streamID := s.streamID(name, id) 80 | 81 | if _, exists := s.events[streamID]; !exists { 82 | s.events[streamID] = []eventMsg{} 83 | } 84 | 85 | streamLength := len(s.events[streamID]) 86 | 87 | if streamLength != version { 88 | return es.ErrAggregateVersionMismatch 89 | } 90 | 91 | for _, event := range root.Events() { 92 | data, err := core.SerializeEvent(event) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | s.events[streamID] = append(s.events[streamID], eventMsg{ 98 | eventName: event.EventName(), 99 | event: data, 100 | }) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | func (s *EventStore) streamID(name, id string) string { 107 | return fmt.Sprintf("%s:%s", name, id) 108 | } 109 | -------------------------------------------------------------------------------- /inmem/event_store_options.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | // EventStoreOption options for EventStore 4 | type EventStoreOption func(*EventStore) 5 | -------------------------------------------------------------------------------- /inmem/producer.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/log" 7 | "github.com/stackus/edat/msg" 8 | ) 9 | 10 | // Producer implements msg.Producer 11 | type Producer struct { 12 | logger log.Logger 13 | } 14 | 15 | var _ msg.Producer = (*Producer)(nil) 16 | 17 | // NewProducer constructs a new Producer 18 | func NewProducer(options ...ProducerOption) *Producer { 19 | p := &Producer{ 20 | logger: log.DefaultLogger, 21 | } 22 | 23 | for _, option := range options { 24 | option(p) 25 | } 26 | 27 | return p 28 | } 29 | 30 | // Send implements msg.Producer.Send 31 | func (p *Producer) Send(_ context.Context, channel string, message msg.Message) error { 32 | if result, exists := channels.Load(channel); exists { 33 | destination := result.(chan msg.Message) 34 | 35 | destination <- message 36 | 37 | p.logger.Trace("message sent to inmem channel", log.String("Channel", channel)) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // Close implements msg.Producer.Close 44 | func (p *Producer) Close(context.Context) error { 45 | p.logger.Trace("closing message destination") 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /inmem/producer_options.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // ProducerOption options for Producer 8 | type ProducerOption func(destination *Producer) 9 | 10 | // WithProducerLogger sets the log.Logger for Producer 11 | func WithProducerLogger(logger log.Logger) ProducerOption { 12 | return func(producer *Producer) { 13 | producer.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /inmem/saga_instance_store.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/stackus/edat/core" 9 | "github.com/stackus/edat/saga" 10 | ) 11 | 12 | // SagaInstanceStore implements saga.InstanceStore 13 | type SagaInstanceStore struct { 14 | instances sync.Map 15 | } 16 | 17 | type instanceData struct { 18 | sagaID string 19 | sagaName string 20 | sagaData core.SagaData 21 | currentStep int 22 | endState bool 23 | compensating bool 24 | } 25 | 26 | var _ saga.InstanceStore = (*SagaInstanceStore)(nil) 27 | 28 | // NewSagaInstanceStore constructs a new SagaInstanceStore 29 | func NewSagaInstanceStore() *SagaInstanceStore { 30 | return &SagaInstanceStore{ 31 | instances: sync.Map{}, 32 | } 33 | } 34 | 35 | // Find implements saga.InstanceStore.Find 36 | func (s *SagaInstanceStore) Find(_ context.Context, sagaName, sagaID string) (*saga.Instance, error) { 37 | if dataT, exists := s.instances.Load(s.instanceID(sagaName, sagaID)); exists { 38 | data := dataT.(instanceData) 39 | 40 | instance := saga.NewSagaInstance(data.sagaName, sagaID, data.sagaData, data.currentStep, data.endState, data.compensating) 41 | 42 | return instance, nil 43 | } 44 | 45 | return nil, nil 46 | } 47 | 48 | // Save implements saga.InstanceStore.Save 49 | func (s *SagaInstanceStore) Save(_ context.Context, instance *saga.Instance) error { 50 | return s.save(instance) 51 | } 52 | 53 | // Update implements saga.InstanceStore.Update 54 | func (s *SagaInstanceStore) Update(_ context.Context, instance *saga.Instance) error { 55 | return s.save(instance) 56 | } 57 | 58 | func (s *SagaInstanceStore) save(instance *saga.Instance) error { 59 | instanceID := s.instanceID(instance.SagaName(), instance.SagaID()) 60 | 61 | s.instances.Store(instanceID, instanceData{ 62 | sagaID: instance.SagaID(), 63 | sagaName: instance.SagaName(), 64 | sagaData: instance.SagaData(), 65 | currentStep: instance.CurrentStep(), 66 | endState: instance.EndState(), 67 | compensating: instance.Compensating(), 68 | }) 69 | 70 | return nil 71 | } 72 | 73 | func (s *SagaInstanceStore) instanceID(sagaName, sagaID string) string { 74 | return fmt.Sprintf("%s:%s", sagaName, sagaID) 75 | } 76 | -------------------------------------------------------------------------------- /inmem/snapshot_store.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | 9 | "github.com/stackus/edat/core" 10 | "github.com/stackus/edat/es" 11 | ) 12 | 13 | // SnapshotStore implements es.AggregateRootStore 14 | type SnapshotStore struct { 15 | strategy es.SnapshotStrategy 16 | snapshots sync.Map 17 | next es.AggregateRootStore 18 | } 19 | 20 | type snapshotMsg struct { 21 | name string 22 | version int 23 | snapshot json.RawMessage 24 | } 25 | 26 | var _ es.AggregateRootStore = (*SnapshotStore)(nil) 27 | 28 | // NewSnapshotStore constructs a new SnapshotStore and returns es.AggregateRootStoreMiddleware 29 | func NewSnapshotStore(options ...SnapshotStoreOption) es.AggregateRootStoreMiddleware { 30 | s := &SnapshotStore{ 31 | strategy: es.DefaultSnapshotStrategy, 32 | snapshots: sync.Map{}, 33 | } 34 | 35 | for _, option := range options { 36 | option(s) 37 | } 38 | 39 | return func(next es.AggregateRootStore) es.AggregateRootStore { 40 | s.next = next 41 | return s 42 | } 43 | } 44 | 45 | // Load implements es.AggregateRootStore.Load 46 | func (s *SnapshotStore) Load(ctx context.Context, root *es.AggregateRoot) error { 47 | name := root.AggregateName() 48 | id := root.AggregateID() 49 | 50 | if result, exists := s.snapshots.Load(s.streamID(name, id)); exists { 51 | message := result.(snapshotMsg) 52 | snapshot, err := core.DeserializeSnapshot(message.name, message.snapshot) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | err = root.LoadSnapshot(snapshot, message.version) 58 | if err != nil { 59 | return err 60 | } 61 | } 62 | 63 | return s.next.Load(ctx, root) 64 | } 65 | 66 | // Save implements es.AggregateRootStore.Save 67 | func (s *SnapshotStore) Save(ctx context.Context, root *es.AggregateRoot) error { 68 | err := s.next.Save(ctx, root) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if !s.strategy.ShouldSnapshot(root) { 74 | return nil 75 | } 76 | 77 | snapshot, err := root.Aggregate().ToSnapshot() 78 | if err != nil { 79 | return err 80 | } 81 | 82 | data, err := core.SerializeSnapshot(snapshot) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | name := root.AggregateName() 88 | id := root.AggregateID() 89 | version := root.PendingVersion() 90 | 91 | s.snapshots.Store(s.streamID(name, id), snapshotMsg{ 92 | name: snapshot.SnapshotName(), 93 | version: version, 94 | snapshot: data, 95 | }) 96 | 97 | return nil 98 | } 99 | 100 | func (s *SnapshotStore) streamID(name, id string) string { 101 | return fmt.Sprintf("%s:%s", name, id) 102 | } 103 | -------------------------------------------------------------------------------- /inmem/snapshot_store_options.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "github.com/stackus/edat/es" 5 | ) 6 | 7 | // SnapshotStoreOption options for SnapshotStore 8 | type SnapshotStoreOption func(store *SnapshotStore) 9 | 10 | // WithSnapshotStoreStrategy sets the snapshotting strategy for SnapshotStore 11 | func WithSnapshotStoreStrategy(strategy es.SnapshotStrategy) SnapshotStoreOption { 12 | return func(store *SnapshotStore) { 13 | store.strategy = strategy 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // FieldType loggable field types 8 | type FieldType int 9 | 10 | // Loggable field types 11 | const ( 12 | StringType FieldType = iota 13 | IntType 14 | DurationType 15 | ErrorType 16 | ) 17 | 18 | // Field type 19 | type Field struct { 20 | Key string 21 | Type FieldType 22 | String string 23 | Int int 24 | Duration time.Duration 25 | Error error 26 | } 27 | 28 | // Logger interface 29 | type Logger interface { 30 | Trace(msg string, fields ...Field) 31 | Debug(msg string, fields ...Field) 32 | Info(msg string, fields ...Field) 33 | Warn(msg string, fields ...Field) 34 | Error(msg string, fields ...Field) 35 | Sub(fields ...Field) Logger 36 | } 37 | 38 | // DefaultLogger is set to a Nop logger 39 | // 40 | // You may reassign this if you wish to avoid having to pass in With*Logger(yourLogger) options 41 | // into many of the constructors to set a custom logger 42 | var DefaultLogger Logger = NewNopLogger() 43 | 44 | // String field value for strings 45 | func String(key string, value string) Field { 46 | return Field{Type: StringType, Key: key, String: value} 47 | } 48 | 49 | // Int field value for ints 50 | func Int(key string, value int) Field { 51 | return Field{Type: IntType, Key: key, Int: value} 52 | } 53 | 54 | // Duration field value to time.Durations 55 | func Duration(key string, value time.Duration) Field { 56 | return Field{Type: DurationType, Key: key, Duration: value} 57 | } 58 | 59 | // Error field value for errors 60 | func Error(err error) Field { 61 | return Field{Type: ErrorType, Key: "error", Error: err} 62 | } 63 | 64 | type nopLogger struct{} 65 | 66 | // NewNopLogger returns a logger that logs to the void 67 | func NewNopLogger() Logger { 68 | return nopLogger{} 69 | } 70 | 71 | func (l nopLogger) Trace(string, ...Field) {} 72 | func (l nopLogger) Debug(string, ...Field) {} 73 | func (l nopLogger) Info(string, ...Field) {} 74 | func (l nopLogger) Warn(string, ...Field) {} 75 | func (l nopLogger) Error(string, ...Field) {} 76 | func (l nopLogger) Sub(...Field) Logger { return l } 77 | -------------------------------------------------------------------------------- /log/logmocks/logger.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package logmocks 4 | 5 | import ( 6 | log "github.com/stackus/edat/log" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Logger is an autogenerated mock type for the Logger type 11 | type Logger struct { 12 | mock.Mock 13 | } 14 | 15 | // Debug provides a mock function with given fields: msg, fields 16 | func (_m *Logger) Debug(msg string, fields ...log.Field) { 17 | _va := make([]interface{}, len(fields)) 18 | for _i := range fields { 19 | _va[_i] = fields[_i] 20 | } 21 | var _ca []interface{} 22 | _ca = append(_ca, msg) 23 | _ca = append(_ca, _va...) 24 | _m.Called(_ca...) 25 | } 26 | 27 | // Error provides a mock function with given fields: msg, fields 28 | func (_m *Logger) Error(msg string, fields ...log.Field) { 29 | _va := make([]interface{}, len(fields)) 30 | for _i := range fields { 31 | _va[_i] = fields[_i] 32 | } 33 | var _ca []interface{} 34 | _ca = append(_ca, msg) 35 | _ca = append(_ca, _va...) 36 | _m.Called(_ca...) 37 | } 38 | 39 | // Info provides a mock function with given fields: msg, fields 40 | func (_m *Logger) Info(msg string, fields ...log.Field) { 41 | _va := make([]interface{}, len(fields)) 42 | for _i := range fields { 43 | _va[_i] = fields[_i] 44 | } 45 | var _ca []interface{} 46 | _ca = append(_ca, msg) 47 | _ca = append(_ca, _va...) 48 | _m.Called(_ca...) 49 | } 50 | 51 | // Sub provides a mock function with given fields: fields 52 | func (_m *Logger) Sub(fields ...log.Field) log.Logger { 53 | _va := make([]interface{}, len(fields)) 54 | for _i := range fields { 55 | _va[_i] = fields[_i] 56 | } 57 | var _ca []interface{} 58 | _ca = append(_ca, _va...) 59 | ret := _m.Called(_ca...) 60 | 61 | var r0 log.Logger 62 | if rf, ok := ret.Get(0).(func(...log.Field) log.Logger); ok { 63 | r0 = rf(fields...) 64 | } else { 65 | if ret.Get(0) != nil { 66 | r0 = ret.Get(0).(log.Logger) 67 | } 68 | } 69 | 70 | return r0 71 | } 72 | 73 | // Trace provides a mock function with given fields: msg, fields 74 | func (_m *Logger) Trace(msg string, fields ...log.Field) { 75 | _va := make([]interface{}, len(fields)) 76 | for _i := range fields { 77 | _va[_i] = fields[_i] 78 | } 79 | var _ca []interface{} 80 | _ca = append(_ca, msg) 81 | _ca = append(_ca, _va...) 82 | _m.Called(_ca...) 83 | } 84 | 85 | // Warn provides a mock function with given fields: msg, fields 86 | func (_m *Logger) Warn(msg string, fields ...log.Field) { 87 | _va := make([]interface{}, len(fields)) 88 | for _i := range fields { 89 | _va[_i] = fields[_i] 90 | } 91 | var _ca []interface{} 92 | _ca = append(_ca, msg) 93 | _ca = append(_ca, _va...) 94 | _m.Called(_ca...) 95 | } 96 | -------------------------------------------------------------------------------- /log/logtest/logger.go: -------------------------------------------------------------------------------- 1 | package logtest 2 | 3 | import ( 4 | "github.com/stackus/edat/log/logmocks" 5 | ) 6 | 7 | func MockLogger(setup func(m *logmocks.Logger)) *logmocks.Logger { 8 | m := &logmocks.Logger{} 9 | setup(m) 10 | return m 11 | } 12 | -------------------------------------------------------------------------------- /msg/command.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // DomainCommand interface for commands that are shared across the domain 8 | type DomainCommand interface { 9 | core.Command 10 | DestinationChannel() string 11 | } 12 | 13 | // Command is a core.Command with message header information 14 | type Command interface { 15 | Command() core.Command 16 | Headers() Headers 17 | } 18 | 19 | type commandMessage struct { 20 | command core.Command 21 | headers Headers 22 | } 23 | 24 | func (m commandMessage) Command() core.Command { 25 | return m.command 26 | } 27 | 28 | func (m commandMessage) Headers() Headers { 29 | return m.headers 30 | } 31 | -------------------------------------------------------------------------------- /msg/command_dispatcher.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/log" 9 | ) 10 | 11 | // CommandHandlerFunc function handlers for msg.Command 12 | type CommandHandlerFunc func(context.Context, Command) ([]Reply, error) 13 | 14 | // CommandDispatcher is a MessageReceiver for Commands 15 | type CommandDispatcher struct { 16 | publisher ReplyMessagePublisher 17 | handlers map[string]CommandHandlerFunc 18 | logger log.Logger 19 | } 20 | 21 | var _ MessageReceiver = (*CommandDispatcher)(nil) 22 | 23 | // NewCommandDispatcher constructs a new CommandDispatcher 24 | func NewCommandDispatcher(publisher ReplyMessagePublisher, options ...CommandDispatcherOption) *CommandDispatcher { 25 | c := &CommandDispatcher{ 26 | publisher: publisher, 27 | handlers: map[string]CommandHandlerFunc{}, 28 | logger: log.DefaultLogger, 29 | } 30 | 31 | for _, option := range options { 32 | option(c) 33 | } 34 | 35 | c.logger.Trace("msg.CommandDispatcher constructed") 36 | 37 | return c 38 | } 39 | 40 | // Handle adds a new Command that will be handled by handler 41 | func (d *CommandDispatcher) Handle(cmd core.Command, handler CommandHandlerFunc) *CommandDispatcher { 42 | d.logger.Trace("command handler added", log.String("CommandName", cmd.CommandName())) 43 | d.handlers[cmd.CommandName()] = handler 44 | return d 45 | } 46 | 47 | // ReceiveMessage implements MessageReceiver.ReceiveMessage 48 | func (d *CommandDispatcher) ReceiveMessage(ctx context.Context, message Message) error { 49 | commandName, err := message.Headers().GetRequired(MessageCommandName) 50 | if err != nil { 51 | d.logger.Error("error reading command name", log.Error(err)) 52 | return nil 53 | } 54 | 55 | logger := d.logger.Sub( 56 | log.String("CommandName", commandName), 57 | log.String("MessageID", message.ID()), 58 | ) 59 | 60 | logger.Debug("received command message") 61 | 62 | // check first for a handler of the command; It is possible commands might be published into channels 63 | // that haven't been registered in our application 64 | handler, exists := d.handlers[commandName] 65 | if !exists { 66 | return nil 67 | } 68 | 69 | logger.Trace("command handler found") 70 | 71 | command, err := core.DeserializeCommand(commandName, message.Payload()) 72 | if err != nil { 73 | logger.Error("error decoding command message payload", log.Error(err)) 74 | return nil 75 | } 76 | 77 | replyChannel, err := message.Headers().GetRequired(MessageCommandReplyChannel) 78 | if err != nil { 79 | logger.Error("error reading reply channel", log.Error(err)) 80 | return nil 81 | } 82 | 83 | correlationHeaders := d.correlationHeaders(message.Headers()) 84 | 85 | cmdMsg := commandMessage{command, correlationHeaders} 86 | 87 | replies, err := handler(ctx, cmdMsg) 88 | if err != nil { 89 | logger.Error("command handler returned an error", log.Error(err)) 90 | rerr := d.sendReplies(ctx, replyChannel, []Reply{WithFailure()}, correlationHeaders) 91 | if rerr != nil { 92 | logger.Error("error sending replies", log.Error(rerr)) 93 | return nil 94 | } 95 | return nil 96 | } 97 | 98 | err = d.sendReplies(ctx, replyChannel, replies, correlationHeaders) 99 | if err != nil { 100 | logger.Error("error sending replies", log.Error(err)) 101 | return nil 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (d *CommandDispatcher) sendReplies(ctx context.Context, replyChannel string, replies []Reply, correlationHeaders Headers) error { 108 | for _, reply := range replies { 109 | err := d.publisher.PublishReply(ctx, reply.Reply(), 110 | WithHeaders(reply.Headers()), 111 | WithHeaders(correlationHeaders), 112 | WithDestinationChannel(replyChannel), 113 | ) 114 | if err != nil { 115 | return err 116 | } 117 | } 118 | 119 | return nil 120 | } 121 | 122 | func (d *CommandDispatcher) correlationHeaders(headers Headers) Headers { 123 | replyHeaders := make(map[string]string) 124 | for key, value := range headers { 125 | if key == MessageCommandName { 126 | continue 127 | } 128 | 129 | if strings.HasPrefix(key, MessageCommandPrefix) { 130 | replyHeader := MessageReplyPrefix + key[len(MessageCommandPrefix):] 131 | replyHeaders[replyHeader] = value 132 | } 133 | } 134 | 135 | return replyHeaders 136 | } 137 | -------------------------------------------------------------------------------- /msg/command_dispatcher_options.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // CommandDispatcherOption options for CommandDispatcher 8 | type CommandDispatcherOption func(consumer *CommandDispatcher) 9 | 10 | // WithCommandDispatcherLogger is an option to set the log.Logger of the CommandDispatcher 11 | func WithCommandDispatcherLogger(logger log.Logger) CommandDispatcherOption { 12 | return func(dispatcher *CommandDispatcher) { 13 | dispatcher.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /msg/command_dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package msg_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/mock" 9 | 10 | "github.com/stackus/edat/core" 11 | "github.com/stackus/edat/core/coretest" 12 | "github.com/stackus/edat/log" 13 | "github.com/stackus/edat/log/logmocks" 14 | "github.com/stackus/edat/log/logtest" 15 | "github.com/stackus/edat/msg" 16 | "github.com/stackus/edat/msg/msgmocks" 17 | "github.com/stackus/edat/msg/msgtest" 18 | ) 19 | 20 | func TestCommandDispatcher_ReceiveMessage(t *testing.T) { 21 | type handler struct { 22 | cmd core.Command 23 | fn msg.CommandHandlerFunc 24 | } 25 | type fields struct { 26 | publisher msg.ReplyMessagePublisher 27 | handlers []handler 28 | logger log.Logger 29 | } 30 | type args struct { 31 | ctx context.Context 32 | message msg.Message 33 | } 34 | 35 | core.RegisterDefaultMarshaller(coretest.NewTestMarshaller()) 36 | core.RegisterCommands(coretest.Command{}) 37 | 38 | tests := map[string]struct { 39 | fields fields 40 | args args 41 | wantErr bool 42 | }{ 43 | "Success": { 44 | fields: fields{ 45 | publisher: msgtest.MockReplyMessagePublisher(func(m *msgmocks.ReplyMessagePublisher) { 46 | m.On("PublishReply", mock.Anything, mock.AnythingOfType("msg.Success"), mock.Anything, mock.Anything, mock.Anything).Return(nil) 47 | }), 48 | handlers: []handler{ 49 | { 50 | cmd: coretest.Command{}, 51 | fn: func(ctx context.Context, command msg.Command) ([]msg.Reply, error) { 52 | return []msg.Reply{msg.WithSuccess()}, nil 53 | }, 54 | }, 55 | }, 56 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 57 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 58 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 59 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 60 | }), 61 | }, 62 | args: args{ 63 | ctx: context.Background(), 64 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 65 | msg.MessageCommandName: coretest.Command{}.CommandName(), 66 | msg.MessageCommandReplyChannel: "reply-channel", 67 | })), 68 | }, 69 | wantErr: false, 70 | }, 71 | "HandlerError": { 72 | fields: fields{ 73 | publisher: msgtest.MockReplyMessagePublisher(func(m *msgmocks.ReplyMessagePublisher) { 74 | m.On("PublishReply", mock.Anything, mock.AnythingOfType("msg.Failure"), mock.Anything, mock.Anything, mock.Anything).Return(nil) 75 | }), 76 | handlers: []handler{ 77 | { 78 | cmd: coretest.Command{}, 79 | fn: func(ctx context.Context, command msg.Command) ([]msg.Reply, error) { 80 | return nil, fmt.Errorf("handler error") 81 | }, 82 | }, 83 | }, 84 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 85 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 86 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 87 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 88 | m.On("Error", "command handler returned an error", mock.Anything) 89 | }), 90 | }, 91 | args: args{ 92 | ctx: context.Background(), 93 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 94 | msg.MessageCommandName: coretest.Command{}.CommandName(), 95 | msg.MessageCommandReplyChannel: "reply-channel", 96 | })), 97 | }, 98 | wantErr: false, 99 | }, 100 | "UnregisteredCommand": { 101 | fields: fields{ 102 | publisher: msgtest.MockReplyMessagePublisher(func(m *msgmocks.ReplyMessagePublisher) {}), 103 | handlers: []handler{ 104 | { 105 | cmd: coretest.UnregisteredCommand{}, 106 | fn: func(ctx context.Context, command msg.Command) ([]msg.Reply, error) { 107 | return []msg.Reply{msg.WithSuccess()}, nil 108 | }, 109 | }, 110 | }, 111 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 112 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 113 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 114 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 115 | m.On("Error", "error decoding command message payload", mock.Anything) 116 | }), 117 | }, 118 | args: args{ 119 | ctx: context.Background(), 120 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 121 | msg.MessageCommandName: coretest.UnregisteredCommand{}.CommandName(), 122 | msg.MessageCommandReplyChannel: "reply-channel", 123 | })), 124 | }, 125 | wantErr: false, 126 | }, 127 | "MissingCommandName": { 128 | fields: fields{ 129 | publisher: msgtest.MockReplyMessagePublisher(func(m *msgmocks.ReplyMessagePublisher) {}), 130 | handlers: []handler{ 131 | { 132 | cmd: coretest.Command{}, 133 | fn: func(ctx context.Context, command msg.Command) ([]msg.Reply, error) { 134 | return []msg.Reply{msg.WithSuccess()}, nil 135 | }, 136 | }, 137 | }, 138 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 139 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 140 | m.On("Error", "error reading command name", mock.Anything) 141 | }), 142 | }, 143 | args: args{ 144 | ctx: context.Background(), 145 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 146 | msg.MessageCommandReplyChannel: "reply-channel", 147 | })), 148 | }, 149 | wantErr: false, 150 | }, 151 | "MissingReplyChannel": { 152 | fields: fields{ 153 | publisher: msgtest.MockReplyMessagePublisher(func(m *msgmocks.ReplyMessagePublisher) {}), 154 | handlers: []handler{ 155 | { 156 | cmd: coretest.Command{}, 157 | fn: func(ctx context.Context, command msg.Command) ([]msg.Reply, error) { 158 | return []msg.Reply{msg.WithSuccess()}, nil 159 | }, 160 | }, 161 | }, 162 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 163 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 164 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 165 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 166 | m.On("Error", "error reading reply channel", mock.Anything) 167 | }), 168 | }, 169 | args: args{ 170 | ctx: context.Background(), 171 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 172 | msg.MessageCommandName: coretest.Command{}.CommandName(), 173 | })), 174 | }, 175 | wantErr: false, 176 | }, 177 | } 178 | for name, tt := range tests { 179 | t.Run(name, func(t *testing.T) { 180 | d := msg.NewCommandDispatcher(tt.fields.publisher, msg.WithCommandDispatcherLogger(tt.fields.logger)) 181 | for _, handler := range tt.fields.handlers { 182 | d.Handle(handler.cmd, handler.fn) 183 | } 184 | if err := d.ReceiveMessage(tt.args.ctx, tt.args.message); (err != nil) != tt.wantErr { 185 | t.Errorf("ReceiveMessage() error = %v, wantErr %v", err, tt.wantErr) 186 | } 187 | mock.AssertExpectationsForObjects(t, tt.fields.publisher, tt.fields.logger) 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /msg/constants.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | // Message header keys 4 | const ( 5 | MessageID = "ID" 6 | MessageDate = "DATE" 7 | MessageChannel = "CHANNEL" 8 | MessageCorrelationID = "CORRELATION_ID" 9 | MessageCausationID = "CAUSATION_ID" 10 | 11 | MessageEventPrefix = "EVENT_" 12 | MessageEventName = MessageEventPrefix + "NAME" 13 | MessageEventEntityName = MessageEventPrefix + "ENTITY_NAME" 14 | MessageEventEntityID = MessageEventPrefix + "ENTITY_ID" 15 | 16 | MessageCommandPrefix = "COMMAND_" 17 | MessageCommandName = MessageCommandPrefix + "NAME" 18 | MessageCommandChannel = MessageCommandPrefix + "CHANNEL" 19 | MessageCommandReplyChannel = MessageCommandPrefix + "REPLY_CHANNEL" 20 | 21 | MessageReplyPrefix = "REPLY_" 22 | MessageReplyName = MessageReplyPrefix + "NAME" 23 | MessageReplyOutcome = MessageReplyPrefix + "OUTCOME" 24 | ) 25 | -------------------------------------------------------------------------------- /msg/consumer.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Consumer is the interface that infrastructures should implement to be used in MessageDispatchers 8 | type Consumer interface { 9 | Listen(ctx context.Context, channel string, consumer ReceiveMessageFunc) error 10 | Close(ctx context.Context) error 11 | } 12 | -------------------------------------------------------------------------------- /msg/entity_event.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // EntityEvent is an event with message header information 8 | type EntityEvent interface { 9 | EntityID() string 10 | EntityName() string 11 | Event() core.Event 12 | Headers() Headers 13 | } 14 | 15 | type entityEventMessage struct { 16 | entityID string 17 | entityName string 18 | event core.Event 19 | headers Headers 20 | } 21 | 22 | func (m entityEventMessage) EntityID() string { 23 | return m.entityID 24 | } 25 | 26 | func (m entityEventMessage) EntityName() string { 27 | return m.entityName 28 | } 29 | 30 | func (m entityEventMessage) Event() core.Event { 31 | return m.event 32 | } 33 | 34 | func (m entityEventMessage) Headers() Headers { 35 | return m.headers 36 | } 37 | -------------------------------------------------------------------------------- /msg/entity_event_dispatcher.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | "github.com/stackus/edat/log" 8 | ) 9 | 10 | // EntityEventHandlerFunc function handlers for msg.EntityEvent 11 | type EntityEventHandlerFunc func(context.Context, EntityEvent) error 12 | 13 | // EntityEventDispatcher is a MessageReceiver for DomainEvents 14 | type EntityEventDispatcher struct { 15 | handlers map[string]EntityEventHandlerFunc 16 | logger log.Logger 17 | } 18 | 19 | var _ MessageReceiver = (*EntityEventDispatcher)(nil) 20 | 21 | // NewEntityEventDispatcher constructs a new EntityEventDispatcher 22 | func NewEntityEventDispatcher(options ...EntityEventDispatcherOption) *EntityEventDispatcher { 23 | c := &EntityEventDispatcher{ 24 | handlers: map[string]EntityEventHandlerFunc{}, 25 | logger: log.DefaultLogger, 26 | } 27 | 28 | for _, option := range options { 29 | option(c) 30 | } 31 | 32 | c.logger.Trace("msg.EntityEventDispatcher constructed") 33 | 34 | return c 35 | } 36 | 37 | // Handle adds a new Event that will be handled by EventMessageFunc handler 38 | func (d *EntityEventDispatcher) Handle(evt core.Event, handler EntityEventHandlerFunc) *EntityEventDispatcher { 39 | d.logger.Trace("entity event handler added", log.String("EventName", evt.EventName())) 40 | d.handlers[evt.EventName()] = handler 41 | return d 42 | } 43 | 44 | // ReceiveMessage implements MessageReceiver.ReceiveMessage 45 | func (d *EntityEventDispatcher) ReceiveMessage(ctx context.Context, message Message) error { 46 | eventName, err := message.Headers().GetRequired(MessageEventName) 47 | if err != nil { 48 | d.logger.Error("error reading event name", log.Error(err)) 49 | return nil 50 | } 51 | 52 | entityName, err := message.Headers().GetRequired(MessageEventEntityName) 53 | if err != nil { 54 | d.logger.Error("error reading entity name", log.Error(err)) 55 | return nil 56 | } 57 | 58 | entityID, err := message.Headers().GetRequired(MessageEventEntityID) 59 | if err != nil { 60 | d.logger.Error("error reading entity id", log.Error(err)) 61 | return nil 62 | } 63 | 64 | logger := d.logger.Sub( 65 | log.String("EntityName", entityName), 66 | log.String("EntityID", entityID), 67 | log.String("EventName", eventName), 68 | log.String("MessageID", message.ID()), 69 | ) 70 | 71 | logger.Debug("received entity event message") 72 | 73 | // check first for a handler of the event; It is possible events might be published into channels 74 | // that haven't been registered in our application 75 | handler, exists := d.handlers[eventName] 76 | if !exists { 77 | return nil 78 | } 79 | 80 | logger.Trace("entity event handler found") 81 | 82 | event, err := core.DeserializeEvent(eventName, message.Payload()) 83 | if err != nil { 84 | logger.Error("error decoding entity event message payload", log.Error(err)) 85 | return nil 86 | } 87 | 88 | evtMsg := entityEventMessage{entityID, entityName, event, message.Headers()} 89 | 90 | err = handler(ctx, evtMsg) 91 | if err != nil { 92 | logger.Error("entity event handler returned an error", log.Error(err)) 93 | } 94 | 95 | return err 96 | } 97 | -------------------------------------------------------------------------------- /msg/entity_event_dispatcher_options.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // EntityEventDispatcherOption options for EntityEventDispatcher 8 | type EntityEventDispatcherOption func(consumer *EntityEventDispatcher) 9 | 10 | // WithEntityEventDispatcherLogger is an option to set the log.Logger of the EntityEventDispatcher 11 | func WithEntityEventDispatcherLogger(logger log.Logger) EntityEventDispatcherOption { 12 | return func(dispatcher *EntityEventDispatcher) { 13 | dispatcher.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /msg/entity_event_dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package msg_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/mock" 9 | 10 | "github.com/stackus/edat/core" 11 | "github.com/stackus/edat/core/coretest" 12 | "github.com/stackus/edat/log" 13 | "github.com/stackus/edat/log/logmocks" 14 | "github.com/stackus/edat/log/logtest" 15 | "github.com/stackus/edat/msg" 16 | ) 17 | 18 | func TestEntityEventDispatcher_ReceiveMessage(t *testing.T) { 19 | type handler struct { 20 | evt core.Event 21 | fn msg.EntityEventHandlerFunc 22 | } 23 | type fields struct { 24 | handlers []handler 25 | logger log.Logger 26 | } 27 | type args struct { 28 | ctx context.Context 29 | message msg.Message 30 | } 31 | 32 | core.RegisterDefaultMarshaller(coretest.NewTestMarshaller()) 33 | core.RegisterEvents(coretest.Event{}) 34 | 35 | tests := map[string]struct { 36 | fields fields 37 | args args 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | fields: fields{ 42 | handlers: []handler{ 43 | { 44 | evt: coretest.Event{}, 45 | fn: func(ctx context.Context, evtMsg msg.EntityEvent) error { 46 | return nil 47 | }, 48 | }, 49 | }, 50 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 51 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 52 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 53 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 54 | }), 55 | }, 56 | args: args{ 57 | ctx: context.Background(), 58 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 59 | msg.MessageEventName: coretest.Event{}.EventName(), 60 | msg.MessageEventEntityName: "entity-name", 61 | msg.MessageEventEntityID: "entity-id", 62 | })), 63 | }, 64 | wantErr: false, 65 | }, 66 | "HandlerError": { 67 | fields: fields{ 68 | handlers: []handler{ 69 | { 70 | evt: coretest.Event{}, 71 | fn: func(ctx context.Context, evtMsg msg.EntityEvent) error { 72 | return fmt.Errorf("handler error") 73 | }, 74 | }, 75 | }, 76 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 77 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 78 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 79 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 80 | m.On("Error", "entity event handler returned an error", mock.Anything) 81 | }), 82 | }, 83 | args: args{ 84 | ctx: context.Background(), 85 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 86 | msg.MessageEventName: coretest.Event{}.EventName(), 87 | msg.MessageEventEntityName: "entity-name", 88 | msg.MessageEventEntityID: "entity-id", 89 | })), 90 | }, 91 | wantErr: true, 92 | }, 93 | "coretest.UnregisteredEvent": { 94 | fields: fields{ 95 | handlers: []handler{ 96 | { 97 | evt: coretest.UnregisteredEvent{}, 98 | fn: func(ctx context.Context, evtMsg msg.EntityEvent) error { 99 | return nil 100 | }, 101 | }, 102 | }, 103 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 104 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 105 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 106 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 107 | m.On("Error", "error decoding entity event message payload", mock.Anything) 108 | }), 109 | }, 110 | args: args{ 111 | ctx: context.Background(), 112 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 113 | msg.MessageEventName: coretest.UnregisteredEvent{}.EventName(), 114 | msg.MessageEventEntityName: "entity-name", 115 | msg.MessageEventEntityID: "entity-id", 116 | })), 117 | }, 118 | wantErr: false, 119 | }, 120 | "MissingEventName": { 121 | fields: fields{ 122 | handlers: []handler{ 123 | { 124 | evt: coretest.Event{}, 125 | fn: func(ctx context.Context, evtMsg msg.EntityEvent) error { 126 | return nil 127 | }, 128 | }, 129 | }, 130 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 131 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 132 | m.On("Error", "error reading event name", mock.Anything) 133 | }), 134 | }, 135 | args: args{ 136 | ctx: context.Background(), 137 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 138 | msg.MessageEventEntityName: "entity-name", 139 | msg.MessageEventEntityID: "entity-id", 140 | })), 141 | }, 142 | wantErr: false, 143 | }, 144 | "MissingEntityName": { 145 | fields: fields{ 146 | handlers: []handler{ 147 | { 148 | evt: coretest.Event{}, 149 | fn: func(ctx context.Context, evtMsg msg.EntityEvent) error { 150 | return nil 151 | }, 152 | }, 153 | }, 154 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 155 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 156 | m.On("Error", "error reading entity name", mock.Anything) 157 | }), 158 | }, 159 | args: args{ 160 | ctx: context.Background(), 161 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 162 | msg.MessageEventName: coretest.Event{}.EventName(), 163 | msg.MessageEventEntityID: "entity-id", 164 | })), 165 | }, 166 | wantErr: false, 167 | }, 168 | "MissingEntityID": { 169 | fields: fields{ 170 | handlers: []handler{ 171 | { 172 | evt: coretest.Event{}, 173 | fn: func(ctx context.Context, evtMsg msg.EntityEvent) error { 174 | return nil 175 | }, 176 | }, 177 | }, 178 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 179 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 180 | m.On("Error", "error reading entity id", mock.Anything) 181 | }), 182 | }, 183 | args: args{ 184 | ctx: context.Background(), 185 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 186 | msg.MessageEventName: coretest.Event{}.EventName(), 187 | msg.MessageEventEntityName: "entity-name", 188 | })), 189 | }, 190 | wantErr: false, 191 | }, 192 | } 193 | for name, tt := range tests { 194 | t.Run(name, func(t *testing.T) { 195 | d := msg.NewEntityEventDispatcher(msg.WithEntityEventDispatcherLogger(tt.fields.logger)) 196 | for _, handler := range tt.fields.handlers { 197 | d.Handle(handler.evt, handler.fn) 198 | } 199 | if err := d.ReceiveMessage(tt.args.ctx, tt.args.message); (err != nil) != tt.wantErr { 200 | t.Errorf("ReceiveMessage() error = %v, wantErr %v", err, tt.wantErr) 201 | } 202 | mock.AssertExpectationsForObjects(t, tt.fields.logger) 203 | }) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /msg/event.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // DomainEvent interface for events that are shared across the domain 8 | type DomainEvent interface { 9 | core.Event 10 | DestinationChannel() string 11 | } 12 | 13 | // Event is an event with message header information 14 | type Event interface { 15 | Event() core.Event 16 | Headers() Headers 17 | } 18 | 19 | type eventMessage struct { 20 | event core.Event 21 | headers Headers 22 | } 23 | 24 | func (m eventMessage) Event() core.Event { 25 | return m.event 26 | } 27 | 28 | func (m eventMessage) Headers() Headers { 29 | return m.headers 30 | } 31 | -------------------------------------------------------------------------------- /msg/event_dispatcher.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | "github.com/stackus/edat/log" 8 | ) 9 | 10 | // EventHandlerFunc function handlers for msg.Event 11 | type EventHandlerFunc func(context.Context, Event) error 12 | 13 | // EventDispatcher is a MessageReceiver for Events 14 | type EventDispatcher struct { 15 | handlers map[string]EventHandlerFunc 16 | logger log.Logger 17 | } 18 | 19 | var _ MessageReceiver = (*EventDispatcher)(nil) 20 | 21 | // NewEventDispatcher constructs a new EventDispatcher 22 | func NewEventDispatcher(options ...EventDispatcherOption) *EventDispatcher { 23 | c := &EventDispatcher{ 24 | handlers: map[string]EventHandlerFunc{}, 25 | logger: log.DefaultLogger, 26 | } 27 | 28 | for _, option := range options { 29 | option(c) 30 | } 31 | 32 | c.logger.Trace("msg.EventDispatcher constructed") 33 | 34 | return c 35 | } 36 | 37 | // Handle adds a new Event that will be handled by EventMessageFunc handler 38 | func (d *EventDispatcher) Handle(evt core.Event, handler EventHandlerFunc) *EventDispatcher { 39 | d.logger.Trace("event handler added", log.String("EventName", evt.EventName())) 40 | d.handlers[evt.EventName()] = handler 41 | return d 42 | } 43 | 44 | // ReceiveMessage implements MessageReceiver.ReceiveMessage 45 | func (d *EventDispatcher) ReceiveMessage(ctx context.Context, message Message) error { 46 | eventName, err := message.Headers().GetRequired(MessageEventName) 47 | if err != nil { 48 | d.logger.Error("error reading event name", log.Error(err)) 49 | return nil 50 | } 51 | 52 | logger := d.logger.Sub( 53 | log.String("EventName", eventName), 54 | log.String("MessageID", message.ID()), 55 | ) 56 | 57 | logger.Debug("received event message") 58 | 59 | // check first for a handler of the event; It is possible events might be published into channels 60 | // that haven't been registered in our application 61 | handler, exists := d.handlers[eventName] 62 | if !exists { 63 | return nil 64 | } 65 | 66 | logger.Trace("event handler found") 67 | 68 | event, err := core.DeserializeEvent(eventName, message.Payload()) 69 | if err != nil { 70 | logger.Error("error decoding event message payload", log.Error(err)) 71 | return nil 72 | } 73 | 74 | evtMsg := eventMessage{event, message.Headers()} 75 | 76 | err = handler(ctx, evtMsg) 77 | if err != nil { 78 | logger.Error("event handler returned an error", log.Error(err)) 79 | } 80 | 81 | return err 82 | } 83 | -------------------------------------------------------------------------------- /msg/event_dispatcher_options.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // EventDispatcherOption options for EventDispatcher 8 | type EventDispatcherOption func(consumer *EventDispatcher) 9 | 10 | // WithEventDispatcherLogger is an option to set the log.Logger of the EventDispatcher 11 | func WithEventDispatcherLogger(logger log.Logger) EventDispatcherOption { 12 | return func(dispatcher *EventDispatcher) { 13 | dispatcher.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /msg/event_dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package msg_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/mock" 9 | 10 | "github.com/stackus/edat/core" 11 | "github.com/stackus/edat/core/coretest" 12 | "github.com/stackus/edat/log" 13 | "github.com/stackus/edat/log/logmocks" 14 | "github.com/stackus/edat/log/logtest" 15 | "github.com/stackus/edat/msg" 16 | ) 17 | 18 | func TestEventDispatcher_ReceiveMessage(t *testing.T) { 19 | type handler struct { 20 | evt core.Event 21 | fn msg.EventHandlerFunc 22 | } 23 | type fields struct { 24 | handlers []handler 25 | logger log.Logger 26 | } 27 | type args struct { 28 | ctx context.Context 29 | message msg.Message 30 | } 31 | 32 | core.RegisterDefaultMarshaller(coretest.NewTestMarshaller()) 33 | core.RegisterEvents(coretest.Event{}) 34 | 35 | tests := map[string]struct { 36 | fields fields 37 | args args 38 | wantErr bool 39 | }{ 40 | "Success": { 41 | fields: fields{ 42 | handlers: []handler{ 43 | { 44 | evt: coretest.Event{}, 45 | fn: func(ctx context.Context, evtMsg msg.Event) error { 46 | return nil 47 | }, 48 | }, 49 | }, 50 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 51 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 52 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 53 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 54 | }), 55 | }, 56 | args: args{ 57 | ctx: context.Background(), 58 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 59 | msg.MessageEventName: coretest.Event{}.EventName(), 60 | })), 61 | }, 62 | wantErr: false, 63 | }, 64 | "HandlerError": { 65 | fields: fields{ 66 | handlers: []handler{ 67 | { 68 | evt: coretest.Event{}, 69 | fn: func(ctx context.Context, evtMsg msg.Event) error { 70 | return fmt.Errorf("handler error") 71 | }, 72 | }, 73 | }, 74 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 75 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 76 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 77 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 78 | m.On("Error", "event handler returned an error", mock.Anything) 79 | }), 80 | }, 81 | args: args{ 82 | ctx: context.Background(), 83 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 84 | msg.MessageEventName: coretest.Event{}.EventName(), 85 | })), 86 | }, 87 | wantErr: true, 88 | }, 89 | "coretest.UnregisteredEvent": { 90 | fields: fields{ 91 | handlers: []handler{ 92 | { 93 | evt: coretest.UnregisteredEvent{}, 94 | fn: func(ctx context.Context, evtMsg msg.Event) error { 95 | return nil 96 | }, 97 | }, 98 | }, 99 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 100 | m.On("Sub", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m) 101 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 102 | m.On("Debug", mock.AnythingOfType("string"), mock.Anything) 103 | m.On("Error", "error decoding event message payload", mock.Anything) 104 | }), 105 | }, 106 | args: args{ 107 | ctx: context.Background(), 108 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{ 109 | msg.MessageEventName: coretest.UnregisteredEvent{}.EventName(), 110 | })), 111 | }, 112 | wantErr: false, 113 | }, 114 | "MissingEventName": { 115 | fields: fields{ 116 | handlers: []handler{ 117 | { 118 | evt: coretest.Event{}, 119 | fn: func(ctx context.Context, evtMsg msg.Event) error { 120 | return nil 121 | }, 122 | }, 123 | }, 124 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 125 | m.On("Trace", mock.AnythingOfType("string"), mock.Anything) 126 | m.On("Error", "error reading event name", mock.Anything) 127 | }), 128 | }, 129 | args: args{ 130 | ctx: context.Background(), 131 | message: msg.NewMessage([]byte(`{"Value":""}`), msg.WithHeaders(map[string]string{})), 132 | }, 133 | wantErr: false, 134 | }, 135 | } 136 | for name, tt := range tests { 137 | t.Run(name, func(t *testing.T) { 138 | d := msg.NewEventDispatcher(msg.WithEventDispatcherLogger(tt.fields.logger)) 139 | for _, handler := range tt.fields.handlers { 140 | d.Handle(handler.evt, handler.fn) 141 | } 142 | if err := d.ReceiveMessage(tt.args.ctx, tt.args.message); (err != nil) != tt.wantErr { 143 | t.Errorf("ReceiveMessage() error = %v, wantErr %v", err, tt.wantErr) 144 | } 145 | mock.AssertExpectationsForObjects(t, tt.fields.logger) 146 | }) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /msg/headers.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Headers a map of strings keyed by Message header keys 8 | type Headers map[string]string 9 | 10 | // Has returned whether or not the given key exists in the headers 11 | func (h Headers) Has(key string) bool { 12 | _, exists := h[key] 13 | 14 | return exists 15 | } 16 | 17 | // Get returns the value for the given key. Returns a blank string if it does not exist 18 | func (h Headers) Get(key string) string { 19 | return h[key] 20 | } 21 | 22 | // GetRequired returns the value for the given key. Returns an error if it does not exist 23 | func (h Headers) GetRequired(key string) (string, error) { 24 | value, exists := h[key] 25 | if !exists { 26 | return "", fmt.Errorf("missing required header `%s`", key) 27 | } 28 | 29 | return value, nil 30 | } 31 | 32 | // Set sets or overwrites the key with the value 33 | func (h Headers) Set(key, value string) { 34 | h[key] = value 35 | } 36 | -------------------------------------------------------------------------------- /msg/message.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | // Message interface for messages containing payloads and headers 8 | type Message interface { 9 | ID() string 10 | Headers() Headers 11 | Payload() []byte 12 | } 13 | 14 | // Message is used to pass events, commands, and replies to and from servers 15 | type message struct { 16 | id string 17 | headers Headers 18 | payload []byte 19 | } 20 | 21 | // NewMessage message constructor 22 | func NewMessage(payload []byte, options ...MessageOption) Message { 23 | id := uuid.New().String() 24 | 25 | m := message{ 26 | id: id, 27 | payload: payload, 28 | headers: map[string]string{MessageID: id}, 29 | } 30 | 31 | for _, option := range options { 32 | option(&m) 33 | } 34 | 35 | return m 36 | } 37 | 38 | // ID returns the message ID 39 | func (m message) ID() string { 40 | return m.id 41 | } 42 | 43 | // Headers returns the message Headers 44 | func (m message) Headers() Headers { 45 | return m.headers 46 | } 47 | 48 | // Payload returns the message payload 49 | func (m message) Payload() []byte { 50 | return m.payload 51 | } 52 | -------------------------------------------------------------------------------- /msg/message_options.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/es" 5 | ) 6 | 7 | // MessageOption options for Message 8 | type MessageOption func(m *message) 9 | 10 | // WithMessageID is an option to set the ID of the Message 11 | func WithMessageID(id string) MessageOption { 12 | return func(m *message) { 13 | m.id = id 14 | m.headers[MessageID] = id 15 | } 16 | } 17 | 18 | // WithDestinationChannel is and option to set the destination of the outgoing Message 19 | // 20 | // This will override the previous value set by interface { DestinationChannel() string } 21 | func WithDestinationChannel(destinationChannel string) MessageOption { 22 | return func(m *message) { 23 | m.headers[MessageChannel] = destinationChannel 24 | } 25 | } 26 | 27 | // WithHeaders is an option to set additional headers onto the Message 28 | func WithHeaders(headers Headers) MessageOption { 29 | return func(m *message) { 30 | for key, value := range headers { 31 | m.headers[key] = value 32 | } 33 | } 34 | } 35 | 36 | // WithAggregateInfo is an option to set additional Aggregate specific headers 37 | func WithAggregateInfo(a *es.AggregateRoot) MessageOption { 38 | return func(m *message) { 39 | m.headers[MessageEventEntityName] = a.AggregateName() 40 | m.headers[MessageEventEntityID] = a.AggregateID() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /msg/message_receiver.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // MessageReceiver interface for channel subscription receivers 8 | type MessageReceiver interface { 9 | ReceiveMessage(context.Context, Message) error 10 | } 11 | 12 | // ReceiveMessageFunc makes it easy to drop in functions as receivers 13 | type ReceiveMessageFunc func(context.Context, Message) error 14 | 15 | // ReceiveMessage implements MessageReceiver.ReceiveMessage 16 | func (f ReceiveMessageFunc) ReceiveMessage(ctx context.Context, message Message) error { 17 | return f(ctx, message) 18 | } 19 | -------------------------------------------------------------------------------- /msg/message_receiver_test.go: -------------------------------------------------------------------------------- 1 | package msg_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stackus/edat/msg" 9 | ) 10 | 11 | func TestReceiveMessageFunc_ReceiveMessage(t *testing.T) { 12 | type args struct { 13 | ctx context.Context 14 | message msg.Message 15 | } 16 | tests := map[string]struct { 17 | f msg.ReceiveMessageFunc 18 | args args 19 | wantErr bool 20 | }{ 21 | "Success": { 22 | f: func(ctx context.Context, m msg.Message) error { 23 | return nil 24 | }, 25 | args: args{ 26 | ctx: context.Background(), 27 | message: msg.NewMessage([]byte(`{}`)), 28 | }, 29 | wantErr: false, 30 | }, 31 | "ReceiverError": { 32 | f: func(ctx context.Context, m msg.Message) error { 33 | return fmt.Errorf("receiver-error") 34 | }, 35 | args: args{ 36 | ctx: context.Background(), 37 | message: msg.NewMessage([]byte(`{}`)), 38 | }, 39 | wantErr: true, 40 | }, 41 | } 42 | for name, tt := range tests { 43 | t.Run(name, func(t *testing.T) { 44 | if err := tt.f.ReceiveMessage(tt.args.ctx, tt.args.message); (err != nil) != tt.wantErr { 45 | t.Errorf("ReceiveMessage() error = %v, wantErr %v", err, tt.wantErr) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /msg/msgmocks/command_message_publisher.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | core "github.com/stackus/edat/core" 9 | mock "github.com/stretchr/testify/mock" 10 | 11 | msg "github.com/stackus/edat/msg" 12 | ) 13 | 14 | // CommandMessagePublisher is an autogenerated mock type for the CommandMessagePublisher type 15 | type CommandMessagePublisher struct { 16 | mock.Mock 17 | } 18 | 19 | // PublishCommand provides a mock function with given fields: ctx, replyChannel, command, options 20 | func (_m *CommandMessagePublisher) PublishCommand(ctx context.Context, replyChannel string, command core.Command, options ...msg.MessageOption) error { 21 | _va := make([]interface{}, len(options)) 22 | for _i := range options { 23 | _va[_i] = options[_i] 24 | } 25 | var _ca []interface{} 26 | _ca = append(_ca, ctx, replyChannel, command) 27 | _ca = append(_ca, _va...) 28 | ret := _m.Called(_ca...) 29 | 30 | var r0 error 31 | if rf, ok := ret.Get(0).(func(context.Context, string, core.Command, ...msg.MessageOption) error); ok { 32 | r0 = rf(ctx, replyChannel, command, options...) 33 | } else { 34 | r0 = ret.Error(0) 35 | } 36 | 37 | return r0 38 | } 39 | -------------------------------------------------------------------------------- /msg/msgmocks/consumer.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | msg "github.com/stackus/edat/msg" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // Consumer is an autogenerated mock type for the Consumer type 13 | type Consumer struct { 14 | mock.Mock 15 | } 16 | 17 | // Close provides a mock function with given fields: ctx 18 | func (_m *Consumer) Close(ctx context.Context) error { 19 | ret := _m.Called(ctx) 20 | 21 | var r0 error 22 | if rf, ok := ret.Get(0).(func(context.Context) error); ok { 23 | r0 = rf(ctx) 24 | } else { 25 | r0 = ret.Error(0) 26 | } 27 | 28 | return r0 29 | } 30 | 31 | // Listen provides a mock function with given fields: ctx, channel, consumer 32 | func (_m *Consumer) Listen(ctx context.Context, channel string, consumer msg.ReceiveMessageFunc) error { 33 | ret := _m.Called(ctx, channel, consumer) 34 | 35 | var r0 error 36 | if rf, ok := ret.Get(0).(func(context.Context, string, msg.ReceiveMessageFunc) error); ok { 37 | r0 = rf(ctx, channel, consumer) 38 | } else { 39 | r0 = ret.Error(0) 40 | } 41 | 42 | return r0 43 | } 44 | -------------------------------------------------------------------------------- /msg/msgmocks/entity_event_message_publisher.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | core "github.com/stackus/edat/core" 9 | mock "github.com/stretchr/testify/mock" 10 | 11 | msg "github.com/stackus/edat/msg" 12 | ) 13 | 14 | // EntityEventMessagePublisher is an autogenerated mock type for the EntityEventMessagePublisher type 15 | type EntityEventMessagePublisher struct { 16 | mock.Mock 17 | } 18 | 19 | // PublishEntityEvents provides a mock function with given fields: ctx, entity, options 20 | func (_m *EntityEventMessagePublisher) PublishEntityEvents(ctx context.Context, entity core.Entity, options ...msg.MessageOption) error { 21 | _va := make([]interface{}, len(options)) 22 | for _i := range options { 23 | _va[_i] = options[_i] 24 | } 25 | var _ca []interface{} 26 | _ca = append(_ca, ctx, entity) 27 | _ca = append(_ca, _va...) 28 | ret := _m.Called(_ca...) 29 | 30 | var r0 error 31 | if rf, ok := ret.Get(0).(func(context.Context, core.Entity, ...msg.MessageOption) error); ok { 32 | r0 = rf(ctx, entity, options...) 33 | } else { 34 | r0 = ret.Error(0) 35 | } 36 | 37 | return r0 38 | } 39 | -------------------------------------------------------------------------------- /msg/msgmocks/event_message_publisher.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | core "github.com/stackus/edat/core" 9 | mock "github.com/stretchr/testify/mock" 10 | 11 | msg "github.com/stackus/edat/msg" 12 | ) 13 | 14 | // EventMessagePublisher is an autogenerated mock type for the EventMessagePublisher type 15 | type EventMessagePublisher struct { 16 | mock.Mock 17 | } 18 | 19 | // PublishEvent provides a mock function with given fields: ctx, event, options 20 | func (_m *EventMessagePublisher) PublishEvent(ctx context.Context, event core.Event, options ...msg.MessageOption) error { 21 | _va := make([]interface{}, len(options)) 22 | for _i := range options { 23 | _va[_i] = options[_i] 24 | } 25 | var _ca []interface{} 26 | _ca = append(_ca, ctx, event) 27 | _ca = append(_ca, _va...) 28 | ret := _m.Called(_ca...) 29 | 30 | var r0 error 31 | if rf, ok := ret.Get(0).(func(context.Context, core.Event, ...msg.MessageOption) error); ok { 32 | r0 = rf(ctx, event, options...) 33 | } else { 34 | r0 = ret.Error(0) 35 | } 36 | 37 | return r0 38 | } 39 | -------------------------------------------------------------------------------- /msg/msgmocks/message_publisher.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | msg "github.com/stackus/edat/msg" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // MessagePublisher is an autogenerated mock type for the MessagePublisher type 13 | type MessagePublisher struct { 14 | mock.Mock 15 | } 16 | 17 | // Publish provides a mock function with given fields: ctx, message 18 | func (_m *MessagePublisher) Publish(ctx context.Context, message msg.Message) error { 19 | ret := _m.Called(ctx, message) 20 | 21 | var r0 error 22 | if rf, ok := ret.Get(0).(func(context.Context, msg.Message) error); ok { 23 | r0 = rf(ctx, message) 24 | } else { 25 | r0 = ret.Error(0) 26 | } 27 | 28 | return r0 29 | } 30 | -------------------------------------------------------------------------------- /msg/msgmocks/message_receiver.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | msg "github.com/stackus/edat/msg" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // MessageReceiver is an autogenerated mock type for the MessageReceiver type 13 | type MessageReceiver struct { 14 | mock.Mock 15 | } 16 | 17 | // ReceiveMessage provides a mock function with given fields: _a0, _a1 18 | func (_m *MessageReceiver) ReceiveMessage(_a0 context.Context, _a1 msg.Message) error { 19 | ret := _m.Called(_a0, _a1) 20 | 21 | var r0 error 22 | if rf, ok := ret.Get(0).(func(context.Context, msg.Message) error); ok { 23 | r0 = rf(_a0, _a1) 24 | } else { 25 | r0 = ret.Error(0) 26 | } 27 | 28 | return r0 29 | } 30 | -------------------------------------------------------------------------------- /msg/msgmocks/message_subscriber.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | msg "github.com/stackus/edat/msg" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // MessageSubscriber is an autogenerated mock type for the MessageSubscriber type 11 | type MessageSubscriber struct { 12 | mock.Mock 13 | } 14 | 15 | // Subscribe provides a mock function with given fields: channel, receiver 16 | func (_m *MessageSubscriber) Subscribe(channel string, receiver msg.MessageReceiver) { 17 | _m.Called(channel, receiver) 18 | } 19 | -------------------------------------------------------------------------------- /msg/msgmocks/producer.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | msg "github.com/stackus/edat/msg" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // Producer is an autogenerated mock type for the Producer type 13 | type Producer struct { 14 | mock.Mock 15 | } 16 | 17 | // Close provides a mock function with given fields: ctx 18 | func (_m *Producer) Close(ctx context.Context) error { 19 | ret := _m.Called(ctx) 20 | 21 | var r0 error 22 | if rf, ok := ret.Get(0).(func(context.Context) error); ok { 23 | r0 = rf(ctx) 24 | } else { 25 | r0 = ret.Error(0) 26 | } 27 | 28 | return r0 29 | } 30 | 31 | // Send provides a mock function with given fields: ctx, channel, message 32 | func (_m *Producer) Send(ctx context.Context, channel string, message msg.Message) error { 33 | ret := _m.Called(ctx, channel, message) 34 | 35 | var r0 error 36 | if rf, ok := ret.Get(0).(func(context.Context, string, msg.Message) error); ok { 37 | r0 = rf(ctx, channel, message) 38 | } else { 39 | r0 = ret.Error(0) 40 | } 41 | 42 | return r0 43 | } 44 | -------------------------------------------------------------------------------- /msg/msgmocks/reply_message_publisher.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package msgmocks 4 | 5 | import ( 6 | context "context" 7 | 8 | core "github.com/stackus/edat/core" 9 | mock "github.com/stretchr/testify/mock" 10 | 11 | msg "github.com/stackus/edat/msg" 12 | ) 13 | 14 | // ReplyMessagePublisher is an autogenerated mock type for the ReplyMessagePublisher type 15 | type ReplyMessagePublisher struct { 16 | mock.Mock 17 | } 18 | 19 | // PublishReply provides a mock function with given fields: ctx, reply, options 20 | func (_m *ReplyMessagePublisher) PublishReply(ctx context.Context, reply core.Reply, options ...msg.MessageOption) error { 21 | _va := make([]interface{}, len(options)) 22 | for _i := range options { 23 | _va[_i] = options[_i] 24 | } 25 | var _ca []interface{} 26 | _ca = append(_ca, ctx, reply) 27 | _ca = append(_ca, _va...) 28 | ret := _m.Called(_ca...) 29 | 30 | var r0 error 31 | if rf, ok := ret.Get(0).(func(context.Context, core.Reply, ...msg.MessageOption) error); ok { 32 | r0 = rf(ctx, reply, options...) 33 | } else { 34 | r0 = ret.Error(0) 35 | } 36 | 37 | return r0 38 | } 39 | -------------------------------------------------------------------------------- /msg/msgtest/commands.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | type ( 4 | Command struct{ Value string } 5 | UnregisteredCommand struct{ Value string } 6 | ) 7 | 8 | func (Command) CommandName() string { return "msgtest.Command" } 9 | func (UnregisteredCommand) CommandName() string { return "msgtest.UnregisteredCommand" } 10 | 11 | func (Command) DestinationChannel() string { return "command-channel" } 12 | func (UnregisteredCommand) DestinationChannel() string { return "command-channel" } 13 | -------------------------------------------------------------------------------- /msg/msgtest/consumer.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | import ( 4 | "github.com/stackus/edat/msg/msgmocks" 5 | ) 6 | 7 | func MockConsumer(setup func(m *msgmocks.Consumer)) *msgmocks.Consumer { 8 | m := &msgmocks.Consumer{} 9 | setup(m) 10 | return m 11 | } 12 | -------------------------------------------------------------------------------- /msg/msgtest/entity.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | import ( 4 | "github.com/stackus/edat/core/coretest" 5 | ) 6 | 7 | type Entity struct { 8 | coretest.Entity 9 | } 10 | 11 | func (Entity) DestinationChannel() string { return "entity-channel" } 12 | -------------------------------------------------------------------------------- /msg/msgtest/events.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | type ( 4 | Event struct{ Value string } 5 | UnregisteredEvent struct{ Value string } 6 | ) 7 | 8 | func (Event) EventName() string { return "msgtest.Event" } 9 | func (UnregisteredEvent) EventName() string { return "msgtest.UnregisteredEvent" } 10 | 11 | func (Event) DestinationChannel() string { return "event-channel" } 12 | func (UnregisteredEvent) DestinationChannel() string { return "event-channel" } 13 | -------------------------------------------------------------------------------- /msg/msgtest/message_receiver.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | import ( 4 | "github.com/stackus/edat/msg/msgmocks" 5 | ) 6 | 7 | func MockMessageReceiver(setup func(m *msgmocks.MessageReceiver)) *msgmocks.MessageReceiver { 8 | m := &msgmocks.MessageReceiver{} 9 | setup(m) 10 | return m 11 | } 12 | -------------------------------------------------------------------------------- /msg/msgtest/producer.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | import ( 4 | "github.com/stackus/edat/msg/msgmocks" 5 | ) 6 | 7 | func MockProducer(setup func(m *msgmocks.Producer)) *msgmocks.Producer { 8 | m := &msgmocks.Producer{} 9 | setup(m) 10 | return m 11 | } 12 | -------------------------------------------------------------------------------- /msg/msgtest/replies.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | type ( 4 | Reply struct{ Value string } 5 | UnregisteredReply struct{ Value string } 6 | ) 7 | 8 | func (Reply) ReplyName() string { return "msgtest.Reply" } 9 | func (UnregisteredReply) ReplyName() string { return "msgtest.UnregisteredReply" } 10 | 11 | func (Reply) DestinationChannel() string { return "reply-channel" } 12 | func (UnregisteredReply) DestinationChannel() string { return "reply-channel" } 13 | -------------------------------------------------------------------------------- /msg/msgtest/reply_message_publisher.go: -------------------------------------------------------------------------------- 1 | package msgtest 2 | 3 | import ( 4 | "github.com/stackus/edat/msg/msgmocks" 5 | ) 6 | 7 | func MockReplyMessagePublisher(setup func(m *msgmocks.ReplyMessagePublisher)) *msgmocks.ReplyMessagePublisher { 8 | m := &msgmocks.ReplyMessagePublisher{} 9 | setup(m) 10 | return m 11 | } 12 | -------------------------------------------------------------------------------- /msg/producer.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Producer is the interface that infrastructures should implement to be used in a Publisher 8 | type Producer interface { 9 | Send(ctx context.Context, channel string, message Message) error 10 | Close(ctx context.Context) error 11 | } 12 | -------------------------------------------------------------------------------- /msg/publisher.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/stackus/edat/core" 9 | "github.com/stackus/edat/log" 10 | ) 11 | 12 | // CommandMessagePublisher interface 13 | type CommandMessagePublisher interface { 14 | PublishCommand(ctx context.Context, replyChannel string, command core.Command, options ...MessageOption) error 15 | } 16 | 17 | // EntityEventMessagePublisher interface 18 | type EntityEventMessagePublisher interface { 19 | PublishEntityEvents(ctx context.Context, entity core.Entity, options ...MessageOption) error 20 | } 21 | 22 | // EventMessagePublisher interface 23 | type EventMessagePublisher interface { 24 | PublishEvent(ctx context.Context, event core.Event, options ...MessageOption) error 25 | } 26 | 27 | // ReplyMessagePublisher interface 28 | type ReplyMessagePublisher interface { 29 | PublishReply(ctx context.Context, reply core.Reply, options ...MessageOption) error 30 | } 31 | 32 | // MessagePublisher interface 33 | type MessagePublisher interface { 34 | Publish(ctx context.Context, message Message) error 35 | } 36 | 37 | var _ CommandMessagePublisher = (*Publisher)(nil) 38 | var _ EntityEventMessagePublisher = (*Publisher)(nil) 39 | var _ EventMessagePublisher = (*Publisher)(nil) 40 | var _ ReplyMessagePublisher = (*Publisher)(nil) 41 | var _ MessagePublisher = (*Publisher)(nil) 42 | 43 | // Publisher send domain events, commands, and replies to the publisher 44 | type Publisher struct { 45 | producer Producer 46 | logger log.Logger 47 | close sync.Once 48 | } 49 | 50 | // NewPublisher constructs a new Publisher 51 | func NewPublisher(producer Producer, options ...PublisherOption) *Publisher { 52 | p := &Publisher{ 53 | producer: producer, 54 | logger: log.DefaultLogger, 55 | } 56 | 57 | for _, option := range options { 58 | option(p) 59 | } 60 | 61 | p.logger.Trace("msg.Publisher constructed") 62 | 63 | return p 64 | } 65 | 66 | // PublishCommand serializes a command into a message with command specific headers and publishes it to a producer 67 | func (p *Publisher) PublishCommand(ctx context.Context, replyChannel string, command core.Command, options ...MessageOption) error { 68 | msgOptions := []MessageOption{ 69 | WithHeaders(map[string]string{ 70 | MessageCommandName: command.CommandName(), 71 | MessageCommandReplyChannel: replyChannel, 72 | }), 73 | } 74 | 75 | if v, ok := command.(interface{ DestinationChannel() string }); ok { 76 | msgOptions = append(msgOptions, WithDestinationChannel(v.DestinationChannel())) 77 | } 78 | 79 | msgOptions = append(msgOptions, options...) 80 | 81 | logger := p.logger.Sub( 82 | log.String("CommandName", command.CommandName()), 83 | ) 84 | 85 | logger.Trace("publishing command") 86 | 87 | payload, err := core.SerializeCommand(command) 88 | if err != nil { 89 | logger.Error("error serializing command payload", log.Error(err)) 90 | return err 91 | } 92 | 93 | message := NewMessage(payload, msgOptions...) 94 | 95 | err = p.Publish(ctx, message) 96 | if err != nil { 97 | logger.Error("error publishing command", log.Error(err)) 98 | } 99 | 100 | return err 101 | } 102 | 103 | // PublishReply serializes a reply into a message with reply specific headers and publishes it to a producer 104 | func (p *Publisher) PublishReply(ctx context.Context, reply core.Reply, options ...MessageOption) error { 105 | msgOptions := []MessageOption{ 106 | WithHeaders(map[string]string{ 107 | MessageReplyName: reply.ReplyName(), 108 | }), 109 | } 110 | 111 | if v, ok := reply.(interface{ DestinationChannel() string }); ok { 112 | msgOptions = append(msgOptions, WithDestinationChannel(v.DestinationChannel())) 113 | } 114 | 115 | msgOptions = append(msgOptions, options...) 116 | 117 | logger := p.logger.Sub( 118 | log.String("ReplyName", reply.ReplyName()), 119 | ) 120 | 121 | logger.Trace("publishing reply") 122 | 123 | payload, err := core.SerializeReply(reply) 124 | if err != nil { 125 | logger.Error("error serializing reply payload", log.Error(err)) 126 | return err 127 | } 128 | 129 | message := NewMessage(payload, msgOptions...) 130 | 131 | err = p.Publish(ctx, message) 132 | if err != nil { 133 | logger.Error("error publishing reply", log.Error(err)) 134 | } 135 | 136 | return err 137 | } 138 | 139 | // PublishEntityEvents serializes entity events into messages with entity specific headers and publishes it to a producer 140 | func (p *Publisher) PublishEntityEvents(ctx context.Context, entity core.Entity, options ...MessageOption) error { 141 | msgOptions := []MessageOption{ 142 | WithHeaders(map[string]string{ 143 | MessageEventEntityID: entity.ID(), 144 | MessageEventEntityName: entity.EntityName(), 145 | MessageChannel: entity.EntityName(), // allow entity name and channel to overlap 146 | }), 147 | } 148 | 149 | if v, ok := entity.(interface{ DestinationChannel() string }); ok { 150 | msgOptions = append(msgOptions, WithDestinationChannel(v.DestinationChannel())) 151 | } 152 | 153 | msgOptions = append(msgOptions, options...) 154 | 155 | for _, event := range entity.Events() { 156 | logger := p.logger.Sub( 157 | log.String("EntityID", entity.ID()), 158 | log.String("EntityName", entity.EntityName()), 159 | ) 160 | 161 | err := p.PublishEvent(ctx, event, msgOptions...) 162 | if err != nil { 163 | logger.Error("error publishing entity event", log.Error(err)) 164 | return err 165 | } 166 | } 167 | 168 | return nil 169 | } 170 | 171 | // PublishEvent serializes an event into a message with event specific headers and publishes it to a producer 172 | func (p *Publisher) PublishEvent(ctx context.Context, event core.Event, options ...MessageOption) error { 173 | msgOptions := []MessageOption{ 174 | WithHeaders(map[string]string{ 175 | MessageEventName: event.EventName(), 176 | }), 177 | } 178 | 179 | if v, ok := event.(interface{ DestinationChannel() string }); ok { 180 | msgOptions = append(msgOptions, WithDestinationChannel(v.DestinationChannel())) 181 | } 182 | 183 | msgOptions = append(msgOptions, options...) 184 | 185 | logger := p.logger.Sub( 186 | log.String("EventName", event.EventName()), 187 | ) 188 | 189 | logger.Trace("publishing event") 190 | 191 | payload, err := core.SerializeEvent(event) 192 | if err != nil { 193 | logger.Error("error serializing event payload", log.Error(err)) 194 | return err 195 | } 196 | 197 | message := NewMessage(payload, msgOptions...) 198 | 199 | err = p.Publish(ctx, message) 200 | if err != nil { 201 | logger.Error("error publishing event", log.Error(err)) 202 | } 203 | 204 | return err 205 | } 206 | 207 | // Publish sends a message off to a producer 208 | func (p *Publisher) Publish(ctx context.Context, message Message) error { 209 | var err error 210 | var channel string 211 | 212 | channel, err = message.Headers().GetRequired(MessageChannel) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | message.Headers()[MessageDate] = time.Now().Format(time.RFC3339) 218 | 219 | // Published messages are request boundaries 220 | if id, exists := message.Headers()[MessageCorrelationID]; !exists || id == "" { 221 | message.Headers()[MessageCorrelationID] = core.GetCorrelationID(ctx) 222 | } 223 | 224 | if id, exists := message.Headers()[MessageCausationID]; !exists || id == "" { 225 | message.Headers()[MessageCausationID] = core.GetRequestID(ctx) 226 | } 227 | 228 | logger := p.logger.Sub( 229 | log.String("MessageID", message.ID()), 230 | log.String("CorrelationID", message.Headers()[MessageCorrelationID]), 231 | log.String("CausationID", message.Headers()[MessageCausationID]), 232 | log.String("Destination", channel), 233 | log.Int("PayloadSize", len(message.Payload())), 234 | ) 235 | 236 | logger.Trace("publishing message") 237 | 238 | err = p.producer.Send(ctx, channel, message) 239 | if err != nil { 240 | logger.Error("error publishing message", log.Error(err)) 241 | return err 242 | } 243 | 244 | return nil 245 | } 246 | 247 | // Stop stops the publisher and underlying producer 248 | func (p *Publisher) Stop(ctx context.Context) (err error) { 249 | defer p.logger.Trace("publisher stopped") 250 | p.close.Do(func() { 251 | err = p.producer.Close(ctx) 252 | }) 253 | 254 | return 255 | } 256 | -------------------------------------------------------------------------------- /msg/publisher_options.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // PublisherOption options for PublisherPublisher 8 | type PublisherOption func(*Publisher) 9 | 10 | // WithPublisherLogger is an option to set the log.Logger of the Publisher 11 | func WithPublisherLogger(logger log.Logger) PublisherOption { 12 | return func(publisher *Publisher) { 13 | publisher.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /msg/register_types.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // RegisterTypes should be called after registering a new marshaller; especially after registering a new default 8 | func RegisterTypes() { 9 | // Need to register the success and failure messages with the msgpack marshaller 10 | core.RegisterReplies(Success{}, Failure{}) 11 | } 12 | -------------------------------------------------------------------------------- /msg/reply.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // Reply outcomes 8 | const ( 9 | ReplyOutcomeSuccess = "SUCCESS" 10 | ReplyOutcomeFailure = "FAILURE" 11 | ) 12 | 13 | // Reply interface 14 | type Reply interface { 15 | Reply() core.Reply 16 | Headers() Headers 17 | } 18 | 19 | type replyMessage struct { 20 | reply core.Reply 21 | headers Headers 22 | } 23 | 24 | // NewReply constructs a new reply with headers 25 | func NewReply(reply core.Reply, headers Headers) Reply { 26 | return &replyMessage{reply, headers} 27 | } 28 | 29 | // Reply returns the core.Reply 30 | func (m replyMessage) Reply() core.Reply { 31 | return m.reply 32 | } 33 | 34 | // Headers returns the msg.Headers 35 | func (m replyMessage) Headers() Headers { 36 | return m.headers 37 | } 38 | 39 | // SuccessReply wraps a reply and returns it as a Success reply 40 | // Deprecated: Use the WithReply() reply builder 41 | func SuccessReply(reply core.Reply) Reply { 42 | if reply == nil { 43 | return &replyMessage{ 44 | reply: Success{}, 45 | headers: map[string]string{ 46 | MessageReplyOutcome: ReplyOutcomeSuccess, 47 | MessageReplyName: Success{}.ReplyName(), 48 | }, 49 | } 50 | } 51 | 52 | return &replyMessage{ 53 | reply: reply, 54 | headers: map[string]string{ 55 | MessageReplyOutcome: ReplyOutcomeSuccess, 56 | MessageReplyName: reply.ReplyName(), 57 | }, 58 | } 59 | } 60 | 61 | // FailureReply wraps a reply and returns it as a Failure reply 62 | // Deprecated: Use the WithReply() reply builder 63 | func FailureReply(reply core.Reply) Reply { 64 | if reply == nil { 65 | return &replyMessage{ 66 | reply: Failure{}, 67 | headers: map[string]string{ 68 | MessageReplyOutcome: ReplyOutcomeFailure, 69 | MessageReplyName: Failure{}.ReplyName(), 70 | }, 71 | } 72 | } 73 | 74 | return &replyMessage{ 75 | reply: reply, 76 | headers: map[string]string{ 77 | MessageReplyOutcome: ReplyOutcomeFailure, 78 | MessageReplyName: reply.ReplyName(), 79 | }, 80 | } 81 | } 82 | 83 | // WithSuccess returns a generic Success reply 84 | func WithSuccess() Reply { 85 | return &replyMessage{ 86 | reply: Success{}, 87 | headers: map[string]string{ 88 | MessageReplyOutcome: ReplyOutcomeSuccess, 89 | MessageReplyName: Success{}.ReplyName(), 90 | }, 91 | } 92 | } 93 | 94 | // WithFailure returns a generic Failure reply 95 | func WithFailure() Reply { 96 | return &replyMessage{ 97 | reply: Failure{}, 98 | headers: map[string]string{ 99 | MessageReplyOutcome: ReplyOutcomeFailure, 100 | MessageReplyName: Failure{}.ReplyName(), 101 | }, 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /msg/reply_builder.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // WithReply starts a reply builder allowing custom headers to be injected 8 | func WithReply(reply core.Reply) *ReplyBuilder { 9 | return &ReplyBuilder{ 10 | reply: reply, 11 | headers: map[string]string{}, 12 | } 13 | } 14 | 15 | // ReplyBuilder is used to build custom replies 16 | type ReplyBuilder struct { 17 | reply core.Reply 18 | headers map[string]string 19 | } 20 | 21 | // Reply replaces the reply to be wrapped 22 | func (b *ReplyBuilder) Reply(reply core.Reply) *ReplyBuilder { 23 | b.reply = reply 24 | return b 25 | } 26 | 27 | // Headers adds headers to include with the reply 28 | func (b *ReplyBuilder) Headers(headers map[string]string) *ReplyBuilder { 29 | for key, value := range headers { 30 | b.headers[key] = value 31 | } 32 | return b 33 | } 34 | 35 | // Success wraps the reply with the custom headers as a Success reply 36 | func (b *ReplyBuilder) Success() Reply { 37 | if b.reply == nil { 38 | b.reply = Success{} 39 | } 40 | 41 | b.headers[MessageReplyOutcome] = ReplyOutcomeSuccess 42 | b.headers[MessageReplyName] = b.reply.ReplyName() 43 | 44 | return &replyMessage{ 45 | reply: b.reply, 46 | headers: b.headers, 47 | } 48 | } 49 | 50 | // Failure wraps the reply with the custom headers as a Failure reply 51 | func (b *ReplyBuilder) Failure() Reply { 52 | if b.reply == nil { 53 | b.reply = Failure{} 54 | } 55 | 56 | b.headers[MessageReplyOutcome] = ReplyOutcomeFailure 57 | b.headers[MessageReplyName] = b.reply.ReplyName() 58 | 59 | return &replyMessage{ 60 | reply: b.reply, 61 | headers: b.headers, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /msg/reply_builder_test.go: -------------------------------------------------------------------------------- 1 | package msg_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/msg" 9 | ) 10 | 11 | func TestReplyBuilder_Failure(t *testing.T) { 12 | type fields struct { 13 | reply core.Reply 14 | headers map[string]string 15 | } 16 | tests := map[string]struct { 17 | fields fields 18 | want msg.Reply 19 | }{ 20 | "Success": { 21 | fields: fields{ 22 | reply: msg.Failure{}, 23 | headers: map[string]string{ 24 | "custom": "value", 25 | }, 26 | }, 27 | want: msg.NewReply(msg.Failure{}, map[string]string{ 28 | msg.MessageReplyName: msg.Failure{}.ReplyName(), 29 | msg.MessageReplyOutcome: msg.ReplyOutcomeFailure, 30 | "custom": "value", 31 | }), 32 | }, 33 | } 34 | for name, tt := range tests { 35 | t.Run(name, func(t *testing.T) { 36 | b := msg.WithReply(tt.fields.reply).Headers(tt.fields.headers) 37 | if got := b.Failure(); !reflect.DeepEqual(got, tt.want) { 38 | t.Errorf("Failure() = %v, want %v", got, tt.want) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestReplyBuilder_Success(t *testing.T) { 45 | type fields struct { 46 | reply core.Reply 47 | headers map[string]string 48 | } 49 | tests := map[string]struct { 50 | fields fields 51 | want msg.Reply 52 | }{ 53 | "Success": { 54 | fields: fields{ 55 | reply: msg.Success{}, 56 | headers: map[string]string{ 57 | "custom": "value", 58 | }, 59 | }, 60 | want: msg.NewReply(msg.Success{}, map[string]string{ 61 | msg.MessageReplyName: msg.Success{}.ReplyName(), 62 | msg.MessageReplyOutcome: msg.ReplyOutcomeSuccess, 63 | "custom": "value", 64 | }), 65 | }, 66 | } 67 | for name, tt := range tests { 68 | t.Run(name, func(t *testing.T) { 69 | b := msg.WithReply(tt.fields.reply).Headers(tt.fields.headers) 70 | if got := b.Success(); !reflect.DeepEqual(got, tt.want) { 71 | t.Errorf("Success() = %v, want %v", got, tt.want) 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /msg/reply_types.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | // Success reply type for generic successful replies to commands 4 | type Success struct{} 5 | 6 | // ReplyName implements core.Reply.ReplyName 7 | func (Success) ReplyName() string { return "edat.msg.Success" } 8 | 9 | // Failure reply type for generic failure replies to commands 10 | type Failure struct{} 11 | 12 | // ReplyName implements core.Reply.ReplyName 13 | func (Failure) ReplyName() string { return "edat.msg.Failure" } 14 | -------------------------------------------------------------------------------- /msg/subscriber.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "golang.org/x/sync/errgroup" 8 | 9 | "github.com/stackus/edat/core" 10 | "github.com/stackus/edat/log" 11 | ) 12 | 13 | // MessageSubscriber interface 14 | type MessageSubscriber interface { 15 | Subscribe(channel string, receiver MessageReceiver) 16 | } 17 | 18 | // Subscriber receives domain events, commands, and replies from the consumer 19 | type Subscriber struct { 20 | consumer Consumer 21 | logger log.Logger 22 | middlewares []func(MessageReceiver) MessageReceiver 23 | receivers map[string][]MessageReceiver 24 | stopping chan struct{} 25 | subscriberWg sync.WaitGroup 26 | close sync.Once 27 | } 28 | 29 | // NewSubscriber constructs a new Subscriber 30 | func NewSubscriber(consumer Consumer, options ...SubscriberOption) *Subscriber { 31 | s := &Subscriber{ 32 | consumer: consumer, 33 | receivers: make(map[string][]MessageReceiver), 34 | stopping: make(chan struct{}), 35 | logger: log.DefaultLogger, 36 | } 37 | 38 | for _, option := range options { 39 | option(s) 40 | } 41 | 42 | s.logger.Trace("msg.Subscriber constructed") 43 | 44 | return s 45 | } 46 | 47 | // Use appends middleware receivers to the receiver stack 48 | func (s *Subscriber) Use(mws ...func(MessageReceiver) MessageReceiver) { 49 | if len(s.receivers) > 0 { 50 | panic("middleware must be added before any subscriptions are made") 51 | } 52 | 53 | s.middlewares = append(s.middlewares, mws...) 54 | } 55 | 56 | // Subscribe connects the receiver with messages from the channel on the consumer 57 | func (s *Subscriber) Subscribe(channel string, receiver MessageReceiver) { 58 | if _, exists := s.receivers[channel]; !exists { 59 | s.receivers[channel] = []MessageReceiver{} 60 | } 61 | s.logger.Trace("subscribed", log.String("Channel", channel)) 62 | s.receivers[channel] = append(s.receivers[channel], s.chain(receiver)) 63 | } 64 | 65 | // Start begins listening to all of the channels sending received messages into them 66 | func (s *Subscriber) Start(ctx context.Context) error { 67 | cCtx, cancel := context.WithCancel(ctx) 68 | defer cancel() 69 | group, gCtx := errgroup.WithContext(cCtx) 70 | 71 | group.Go(func() error { 72 | select { 73 | case <-s.stopping: 74 | cancel() 75 | case <-gCtx.Done(): 76 | } 77 | 78 | return nil 79 | }) 80 | 81 | for c, r := range s.receivers { 82 | // reassign to avoid issues with anonymous func 83 | channel := c 84 | receivers := r 85 | 86 | s.subscriberWg.Add(1) 87 | 88 | group.Go(func() error { 89 | defer s.subscriberWg.Done() 90 | receiveMessageFunc := func(mCtx context.Context, message Message) error { 91 | mCtx = core.SetRequestContext( 92 | mCtx, 93 | message.ID(), 94 | message.Headers().Get(MessageCorrelationID), 95 | message.Headers().Get(MessageCausationID), 96 | ) 97 | 98 | s.logger.Trace("received message", 99 | log.String("MessageID", message.ID()), 100 | log.String("CorrelationID", message.Headers().Get(MessageCorrelationID)), 101 | log.String("CausationID", message.Headers().Get(MessageCausationID)), 102 | log.Int("PayloadSize", len(message.Payload())), 103 | ) 104 | 105 | rGroup, rCtx := errgroup.WithContext(mCtx) 106 | for _, r2 := range receivers { 107 | receiver := r2 108 | rGroup.Go(func() error { 109 | return receiver.ReceiveMessage(rCtx, message) 110 | }) 111 | } 112 | 113 | return rGroup.Wait() 114 | } 115 | err := s.consumer.Listen(gCtx, channel, receiveMessageFunc) 116 | if err != nil { 117 | s.logger.Error("consumer stopped and returned an error", log.Error(err)) 118 | return err 119 | } 120 | 121 | return nil 122 | }) 123 | } 124 | 125 | return group.Wait() 126 | } 127 | 128 | // Stop stops the consumer and underlying consumer 129 | func (s *Subscriber) Stop(ctx context.Context) (err error) { 130 | s.close.Do(func() { 131 | close(s.stopping) 132 | 133 | done := make(chan struct{}) 134 | go func() { 135 | err = s.consumer.Close(ctx) 136 | s.subscriberWg.Wait() 137 | close(done) 138 | }() 139 | 140 | select { 141 | case <-done: 142 | s.logger.Trace("all receivers are done") 143 | case <-ctx.Done(): 144 | s.logger.Warn("timed out waiting for all receivers to close") 145 | } 146 | }) 147 | 148 | return 149 | } 150 | 151 | func (s *Subscriber) chain(receiver MessageReceiver) MessageReceiver { 152 | if len(s.middlewares) == 0 { 153 | return receiver 154 | } 155 | 156 | r := s.middlewares[len(s.middlewares)-1](receiver) 157 | for i := len(s.middlewares) - 2; i >= 0; i-- { 158 | r = s.middlewares[i](r) 159 | } 160 | 161 | return r 162 | } 163 | -------------------------------------------------------------------------------- /msg/subscriber_options.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // SubscriberOption options for MessageConsumers 8 | type SubscriberOption func(*Subscriber) 9 | 10 | // WithSubscriberLogger is an option to set the log.Logger of the Subscriber 11 | func WithSubscriberLogger(logger log.Logger) SubscriberOption { 12 | return func(subscriber *Subscriber) { 13 | subscriber.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /msg/subscriber_test.go: -------------------------------------------------------------------------------- 1 | package msg_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/mock" 10 | 11 | "github.com/stackus/edat/log" 12 | "github.com/stackus/edat/log/logmocks" 13 | "github.com/stackus/edat/log/logtest" 14 | "github.com/stackus/edat/msg" 15 | "github.com/stackus/edat/msg/msgmocks" 16 | "github.com/stackus/edat/msg/msgtest" 17 | ) 18 | 19 | func TestSubscriber_Start(t *testing.T) { 20 | type fields struct { 21 | consumer msg.Consumer 22 | logger log.Logger 23 | middlewares []func(msg.MessageReceiver) msg.MessageReceiver 24 | receivers map[string]msg.MessageReceiver 25 | } 26 | type args struct { 27 | ctx context.Context 28 | } 29 | tests := map[string]struct { 30 | fields fields 31 | args args 32 | wantErr bool 33 | }{ 34 | "Success": { 35 | fields: fields{ 36 | consumer: msgtest.MockConsumer(func(m *msgmocks.Consumer) { 37 | m.On("Listen", mock.Anything, "channel", mock.Anything).Return(nil) 38 | }), 39 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 40 | m.On("Trace", mock.Anything, mock.Anything) 41 | }), 42 | middlewares: []func(msg.MessageReceiver) msg.MessageReceiver{ 43 | func(next msg.MessageReceiver) msg.MessageReceiver { 44 | return next 45 | }, 46 | }, 47 | receivers: map[string]msg.MessageReceiver{ 48 | "channel": msgtest.MockMessageReceiver(func(m *msgmocks.MessageReceiver) {}), 49 | }, 50 | }, 51 | args: args{ 52 | ctx: context.Background(), 53 | }, 54 | wantErr: false, 55 | }, 56 | } 57 | for name, tt := range tests { 58 | t.Run(name, func(t *testing.T) { 59 | s := msg.NewSubscriber(tt.fields.consumer, msg.WithSubscriberLogger(tt.fields.logger)) 60 | s.Use(tt.fields.middlewares...) 61 | for channel, receiver := range tt.fields.receivers { 62 | s.Subscribe(channel, receiver) 63 | } 64 | ctx, cancel := context.WithTimeout(tt.args.ctx, 1*time.Millisecond) 65 | defer cancel() 66 | 67 | err := s.Start(ctx) 68 | if (err != nil) != tt.wantErr { 69 | t.Errorf("Start() error = %v, wantErr %v", err, tt.wantErr) 70 | } 71 | mock.AssertExpectationsForObjects(t, tt.fields.consumer, tt.fields.logger) 72 | }) 73 | } 74 | } 75 | 76 | func TestSubscriber_Stop(t *testing.T) { 77 | type fields struct { 78 | consumer msg.Consumer 79 | logger log.Logger 80 | middlewares []func(msg.MessageReceiver) msg.MessageReceiver 81 | receivers map[string]msg.MessageReceiver 82 | } 83 | type args struct { 84 | ctx context.Context 85 | } 86 | tests := map[string]struct { 87 | fields fields 88 | args args 89 | wantStartErr bool 90 | wantStopErr bool 91 | }{ 92 | "Success": { 93 | fields: fields{ 94 | consumer: msgtest.MockConsumer(func(m *msgmocks.Consumer) { 95 | m.On("Listen", mock.Anything, "channel", mock.Anything).Return(nil) 96 | m.On("Close", mock.Anything).Return(nil) 97 | }), 98 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 99 | m.On("Trace", "msg.Subscriber constructed", mock.Anything) 100 | m.On("Trace", "subscribed", mock.Anything) 101 | m.On("Trace", "all receivers are done", mock.Anything) 102 | }), 103 | middlewares: []func(msg.MessageReceiver) msg.MessageReceiver{}, 104 | receivers: map[string]msg.MessageReceiver{ 105 | "channel": msgtest.MockMessageReceiver(func(m *msgmocks.MessageReceiver) {}), 106 | }, 107 | }, 108 | args: args{ 109 | ctx: context.Background(), 110 | }, 111 | wantStopErr: false, 112 | }, 113 | "ConsumerError": { 114 | fields: fields{ 115 | consumer: msgtest.MockConsumer(func(m *msgmocks.Consumer) { 116 | m.On("Listen", mock.Anything, "channel", mock.Anything).Return(fmt.Errorf("consumer-error")) 117 | m.On("Close", mock.Anything).Return(nil) 118 | }), 119 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 120 | m.On("Trace", mock.Anything, mock.Anything) 121 | m.On("Error", "consumer stopped and returned an error", mock.Anything) 122 | }), 123 | middlewares: []func(msg.MessageReceiver) msg.MessageReceiver{}, 124 | receivers: map[string]msg.MessageReceiver{ 125 | "channel": msgtest.MockMessageReceiver(func(m *msgmocks.MessageReceiver) {}), 126 | }, 127 | }, 128 | args: args{ 129 | ctx: context.Background(), 130 | }, 131 | wantStartErr: true, 132 | wantStopErr: false, 133 | }, 134 | } 135 | for name, tt := range tests { 136 | t.Run(name, func(t *testing.T) { 137 | s := msg.NewSubscriber(tt.fields.consumer, msg.WithSubscriberLogger(tt.fields.logger)) 138 | s.Use(tt.fields.middlewares...) 139 | for channel, receiver := range tt.fields.receivers { 140 | s.Subscribe(channel, receiver) 141 | } 142 | stopped := make(chan struct{}) 143 | var startErr error 144 | go func() { 145 | startErr = s.Start(context.Background()) 146 | close(stopped) 147 | }() 148 | time.Sleep(1 * time.Millisecond) // hack to give goroutine time to start and avoid a data race 149 | err := s.Stop(tt.args.ctx) 150 | <-stopped 151 | if (err != nil) != tt.wantStopErr { 152 | t.Errorf("Stop() error = %v, wantErr %v", err, tt.wantStopErr) 153 | } 154 | if (startErr != nil) != tt.wantStartErr { 155 | t.Errorf("Start() error = %v, wantErr %v", startErr, tt.wantStartErr) 156 | } 157 | mock.AssertExpectationsForObjects(t, tt.fields.consumer, tt.fields.logger) 158 | }) 159 | } 160 | } 161 | 162 | func TestSubscriber_Subscribe(t *testing.T) { 163 | type receivers struct { 164 | channel string 165 | receiver msg.MessageReceiver 166 | } 167 | type fields struct { 168 | consumer msg.Consumer 169 | logger log.Logger 170 | } 171 | type args struct { 172 | receivers []receivers 173 | } 174 | tests := map[string]struct { 175 | fields fields 176 | args args 177 | wantPanic bool 178 | }{ 179 | "Success": { 180 | fields: fields{ 181 | consumer: msgtest.MockConsumer(func(m *msgmocks.Consumer) {}), 182 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 183 | m.On("Trace", mock.Anything, mock.Anything) 184 | }), 185 | }, 186 | args: args{ 187 | receivers: []receivers{ 188 | { 189 | channel: "channel", 190 | receiver: msgtest.MockMessageReceiver(func(m *msgmocks.MessageReceiver) {}), 191 | }, 192 | }, 193 | }, 194 | wantPanic: false, 195 | }, 196 | "Duplicate": { 197 | fields: fields{ 198 | consumer: msgtest.MockConsumer(func(m *msgmocks.Consumer) {}), 199 | logger: logtest.MockLogger(func(m *logmocks.Logger) { 200 | m.On("Trace", mock.Anything, mock.Anything) 201 | }), 202 | }, 203 | args: args{ 204 | receivers: []receivers{ 205 | { 206 | channel: "channel", 207 | receiver: msgtest.MockMessageReceiver(func(m *msgmocks.MessageReceiver) {}), 208 | }, 209 | { 210 | channel: "channel", 211 | receiver: msgtest.MockMessageReceiver(func(m *msgmocks.MessageReceiver) {}), 212 | }, 213 | }, 214 | }, 215 | wantPanic: false, 216 | }, 217 | } 218 | for name, tt := range tests { 219 | t.Run(name, func(t *testing.T) { 220 | s := msg.NewSubscriber(tt.fields.consumer, msg.WithSubscriberLogger(tt.fields.logger)) 221 | defer func() { 222 | if r := recover(); (r != nil) != tt.wantPanic { 223 | t.Errorf("Subscribe() = %v, wantPanic %v", r, tt.wantPanic) 224 | } 225 | mock.AssertExpectationsForObjects(t, tt.fields.consumer, tt.fields.logger) 226 | }() 227 | for _, r := range tt.args.receivers { 228 | s.Subscribe(r.channel, r.receiver) 229 | } 230 | }) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /outbox/constants.go: -------------------------------------------------------------------------------- 1 | package outbox 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/stackus/edat/retry" 7 | ) 8 | 9 | // Package defaults 10 | const ( 11 | DefaultMessagesPerPolling = 500 12 | DefaultPollingInterval = 500 * time.Millisecond 13 | DefaultPurgeOlderThan = 60 * time.Second 14 | DefaultPurgeInterval = 30 * time.Second 15 | 16 | DefaultMaxRetries = 100 17 | DefaultRetryMultiplier = 1.25 18 | DefaultRetryRandomizationFactor = 0.33 19 | ) 20 | 21 | // DefaultRetryer with exponential backoff strategy 22 | var DefaultRetryer = retry.NewExponentialBackoff( 23 | retry.WithBackoffInitialInterval(DefaultPollingInterval), 24 | retry.WithBackoffMaxRetries(DefaultMaxRetries), 25 | retry.WithBackoffMultiplier(DefaultRetryMultiplier), 26 | retry.WithBackoffRandomizationFactor(DefaultRetryRandomizationFactor), 27 | ) 28 | -------------------------------------------------------------------------------- /outbox/message.go: -------------------------------------------------------------------------------- 1 | package outbox 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/stackus/edat/msg" 7 | ) 8 | 9 | // Message struct for the temporary form of a Producers msg.Message 10 | type Message struct { 11 | MessageID string 12 | Destination string 13 | Payload []byte 14 | Headers []byte 15 | } 16 | 17 | type message struct { 18 | id string 19 | headers msg.Headers 20 | payload []byte 21 | } 22 | 23 | // ToMessage converts this form back to msg.Message or returns an error when headers cannot be unmarshalled 24 | func (m Message) ToMessage() (msg.Message, error) { 25 | var headers map[string]string 26 | 27 | err := json.Unmarshal(m.Headers, &headers) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return message{ 33 | id: m.MessageID, 34 | headers: headers, 35 | payload: m.Payload, 36 | }, nil 37 | } 38 | 39 | func (m message) ID() string { 40 | return m.id 41 | } 42 | 43 | func (m message) Headers() msg.Headers { 44 | return m.headers 45 | } 46 | 47 | func (m message) Payload() []byte { 48 | return m.payload 49 | } 50 | -------------------------------------------------------------------------------- /outbox/message_processor.go: -------------------------------------------------------------------------------- 1 | package outbox 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // MessageProcessor interface 8 | type MessageProcessor interface { 9 | Start(ctx context.Context) error 10 | Stop(ctx context.Context) error 11 | } 12 | -------------------------------------------------------------------------------- /outbox/message_store.go: -------------------------------------------------------------------------------- 1 | package outbox 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // MessageStore interface 9 | type MessageStore interface { 10 | Fetch(ctx context.Context, limit int) ([]Message, error) 11 | Save(ctx context.Context, message Message) error 12 | MarkPublished(ctx context.Context, messageIDs []string) error 13 | PurgePublished(ctx context.Context, olderThan time.Duration) error 14 | } 15 | -------------------------------------------------------------------------------- /outbox/polling_processor.go: -------------------------------------------------------------------------------- 1 | package outbox 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "golang.org/x/sync/errgroup" 9 | 10 | "github.com/stackus/edat/log" 11 | "github.com/stackus/edat/msg" 12 | "github.com/stackus/edat/retry" 13 | ) 14 | 15 | // PollingProcessor implements MessageProcessor 16 | type PollingProcessor struct { 17 | in MessageStore 18 | out msg.MessagePublisher 19 | messagesPerPolling int 20 | pollingInterval time.Duration 21 | purgeOlderThan time.Duration 22 | purgeInterval time.Duration 23 | retryer retry.Retryer 24 | logger log.Logger 25 | stopping chan struct{} 26 | close sync.Once 27 | } 28 | 29 | var _ MessageProcessor = (*PollingProcessor)(nil) 30 | 31 | // NewPollingProcessor constructs a new PollingProcessor 32 | func NewPollingProcessor(in MessageStore, out msg.MessagePublisher, options ...PollingProcessorOption) *PollingProcessor { 33 | p := &PollingProcessor{ 34 | in: in, 35 | out: out, 36 | messagesPerPolling: DefaultMessagesPerPolling, 37 | pollingInterval: DefaultPollingInterval, 38 | purgeOlderThan: DefaultPurgeOlderThan, 39 | purgeInterval: DefaultPurgeInterval, 40 | retryer: DefaultRetryer, 41 | logger: log.DefaultLogger, 42 | stopping: make(chan struct{}), 43 | } 44 | 45 | for _, option := range options { 46 | option(p) 47 | } 48 | 49 | p.logger.Trace("outbox.PollingProcessor constructed") 50 | 51 | return p 52 | } 53 | 54 | // Start implements MessageProcessor.Start 55 | func (p *PollingProcessor) Start(ctx context.Context) error { 56 | cCtx, cancel := context.WithCancel(ctx) 57 | defer cancel() 58 | group, gCtx := errgroup.WithContext(cCtx) 59 | 60 | group.Go(func() error { 61 | if <-p.stopping; true { 62 | cancel() 63 | } 64 | 65 | return nil 66 | }) 67 | 68 | group.Go(func() error { 69 | return p.processMessages(gCtx) 70 | }) 71 | 72 | group.Go(func() error { 73 | return p.purgePublished(gCtx) 74 | }) 75 | 76 | p.logger.Trace("processor started") 77 | 78 | return group.Wait() 79 | } 80 | 81 | // Stop implements MessageProcessor.Stop 82 | func (p *PollingProcessor) Stop(ctx context.Context) (err error) { 83 | p.close.Do(func() { 84 | close(p.stopping) 85 | 86 | done := make(chan struct{}) 87 | go func() { 88 | // anything to wait for? 89 | close(done) 90 | }() 91 | 92 | select { 93 | case <-done: 94 | p.logger.Trace("done with internal cleanup") 95 | case <-ctx.Done(): 96 | p.logger.Warn("timed out waiting for internal cleanup to complete") 97 | } 98 | }) 99 | 100 | return 101 | } 102 | 103 | func (p *PollingProcessor) processMessages(ctx context.Context) error { 104 | pollingTimer := time.NewTimer(0) 105 | 106 | for { 107 | var err error 108 | var messages []Message 109 | 110 | err = p.retryer.Retry(ctx, func() error { 111 | messages, err = p.in.Fetch(ctx, p.messagesPerPolling) 112 | return err 113 | }) 114 | if err != nil { 115 | p.logger.Error("error fetching messages", log.Error(err)) 116 | return err 117 | } 118 | 119 | if len(messages) > 0 { 120 | p.logger.Trace("processing messages", log.Int("MessageCount", len(messages))) 121 | ids := make([]string, 0, len(messages)) 122 | for _, message := range messages { 123 | err := p.processMessage(ctx, message) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | ids = append(ids, message.MessageID) 129 | } 130 | 131 | err = p.retryer.Retry(ctx, func() error { 132 | return p.in.MarkPublished(ctx, ids) 133 | }) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | continue 139 | } 140 | 141 | if !pollingTimer.Stop() { 142 | select { 143 | case <-pollingTimer.C: 144 | default: 145 | } 146 | } 147 | 148 | pollingTimer.Reset(p.pollingInterval) 149 | 150 | select { 151 | case <-ctx.Done(): 152 | return nil 153 | case <-pollingTimer.C: 154 | } 155 | } 156 | } 157 | 158 | func (p *PollingProcessor) processMessage(ctx context.Context, message Message) error { 159 | var err error 160 | var outgoingMsg msg.Message 161 | 162 | logger := p.logger.Sub( 163 | log.String("MessageID", message.MessageID), 164 | log.String("DestinationChannel", message.Destination), 165 | ) 166 | 167 | outgoingMsg, err = message.ToMessage() 168 | if err != nil { 169 | logger.Error("error with transforming stored message", log.Error(err)) 170 | // TODO this has potential to halt processing; systems need to be in place to fix or address 171 | return err 172 | } 173 | err = p.out.Publish(ctx, outgoingMsg) 174 | if err != nil { 175 | logger.Error("error publishing message", log.Error(err)) 176 | // TODO this has potential to halt processing; systems need to be in place to fix or address 177 | return err 178 | } 179 | 180 | return nil 181 | } 182 | 183 | func (p *PollingProcessor) purgePublished(ctx context.Context) error { 184 | purgeTimer := time.NewTimer(0) 185 | 186 | for { 187 | err := p.retryer.Retry(ctx, func() error { 188 | return p.in.PurgePublished(ctx, p.purgeOlderThan) 189 | }) 190 | if err != nil { 191 | p.logger.Error("error purging published messages", log.Error(err)) 192 | return err 193 | } 194 | 195 | if !purgeTimer.Stop() { 196 | select { 197 | case <-purgeTimer.C: 198 | default: 199 | } 200 | } 201 | 202 | purgeTimer.Reset(p.purgeInterval) 203 | 204 | select { 205 | case <-ctx.Done(): 206 | return nil 207 | case <-purgeTimer.C: 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /outbox/polling_processor_options.go: -------------------------------------------------------------------------------- 1 | package outbox 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/stackus/edat/log" 7 | "github.com/stackus/edat/retry" 8 | ) 9 | 10 | // PollingProcessorOption options for PollingProcessor 11 | type PollingProcessorOption func(*PollingProcessor) 12 | 13 | // WithPollingProcessorMessagesPerPolling sets the number of messages to fetch for PollingProcessor 14 | func WithPollingProcessorMessagesPerPolling(messagesPerPolling int) PollingProcessorOption { 15 | return func(processor *PollingProcessor) { 16 | processor.messagesPerPolling = messagesPerPolling 17 | } 18 | } 19 | 20 | // WithPollingProcessorPollingInterval sets the interval between attempts to fetch new messages for PollingProcessor 21 | func WithPollingProcessorPollingInterval(pollingInterval time.Duration) PollingProcessorOption { 22 | return func(processor *PollingProcessor) { 23 | processor.pollingInterval = pollingInterval 24 | } 25 | } 26 | 27 | // WithPollingProcessorRetryer sets the retry strategy for failed calls for PollingProcessor 28 | func WithPollingProcessorRetryer(retryer retry.Retryer) PollingProcessorOption { 29 | return func(processor *PollingProcessor) { 30 | processor.retryer = retryer 31 | } 32 | } 33 | 34 | // WithPollingProcessorPurgeOlderThan sets the max age of published messages to purge for PollingProcessor 35 | func WithPollingProcessorPurgeOlderThan(purgeOtherThan time.Duration) PollingProcessorOption { 36 | return func(processor *PollingProcessor) { 37 | processor.purgeOlderThan = purgeOtherThan 38 | } 39 | } 40 | 41 | // WithPollingProcessorPurgeInterval sets the interval between attempts to purge published messages for PollingProcessor 42 | func WithPollingProcessorPurgeInterval(purgeInterval time.Duration) PollingProcessorOption { 43 | return func(processor *PollingProcessor) { 44 | processor.purgeInterval = purgeInterval 45 | } 46 | } 47 | 48 | // WithPollingProcessorLogger sets the log.Logger for PollingProcessor 49 | func WithPollingProcessorLogger(logger log.Logger) PollingProcessorOption { 50 | return func(processor *PollingProcessor) { 51 | processor.logger = logger 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /retry/backoff.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "time" 9 | ) 10 | 11 | // Backoff is a configurable retry backoff strategy 12 | type Backoff struct { 13 | maxRetries int 14 | initialInterval time.Duration 15 | maxInterval time.Duration 16 | maxElapsed time.Duration 17 | multiplier float64 18 | randomization float64 19 | } 20 | 21 | // NewBackoff constructs a new Backoff 22 | func NewBackoff(options ...BackoffOption) *Backoff { 23 | b := &Backoff{ 24 | maxRetries: DefaultMaxRetries, 25 | initialInterval: DefaultInitialInterval, 26 | maxInterval: DefaultMaxInterval, 27 | maxElapsed: 0, 28 | multiplier: 1, 29 | randomization: 0, 30 | } 31 | 32 | for _, option := range options { 33 | option(b) 34 | } 35 | 36 | // Simple infinite retries check 37 | if b.maxRetries == 0 && b.maxElapsed == 0 { 38 | panic("backoff: cannot set both maxRetries and maxElapsed to zero") 39 | } 40 | 41 | return b 42 | } 43 | 44 | // Retry executes a command until it succeeds, encounters an ErrDoNotRetry error, or reaches the backoff strategy limits 45 | func (b Backoff) Retry(ctx context.Context, fn func() error) error { 46 | tries := 0 47 | started := time.Now() 48 | interval := b.initialInterval 49 | 50 | sleepTimer := time.NewTimer(0) 51 | defer sleepTimer.Stop() 52 | 53 | for { 54 | err := fn() 55 | if err == nil { 56 | return nil 57 | } 58 | 59 | var doNotRetry *ErrDoNotRetry 60 | if errors.As(err, &doNotRetry) { 61 | return doNotRetry.err 62 | } 63 | 64 | tries++ 65 | if b.maxRetries != 0 && tries >= b.maxRetries { 66 | return fmt.Errorf("%v: %w", MaxRetriesExceeded, err) 67 | } 68 | 69 | if b.maxElapsed != 0 && started.Add(b.maxElapsed).After(time.Now()) { 70 | return fmt.Errorf("%v: %w", MaxElapsedExceeded, err) 71 | } 72 | 73 | // ensure the timer is stopped and drained 74 | if !sleepTimer.Stop() { 75 | select { 76 | case <-sleepTimer.C: 77 | default: 78 | } 79 | } 80 | 81 | sleepTimer.Reset(interval) 82 | 83 | select { 84 | case <-ctx.Done(): 85 | return ctx.Err() 86 | case <-sleepTimer.C: 87 | interval = b.nextInterval(interval) 88 | } 89 | } 90 | } 91 | 92 | func (b Backoff) nextInterval(lastInterval time.Duration) time.Duration { 93 | // Either there isn't any delay or there is not growth 94 | if b.initialInterval == 0 || lastInterval == b.maxInterval { 95 | return lastInterval 96 | } 97 | 98 | next := float64(lastInterval) * b.multiplier 99 | 100 | // RandomizationFactor / Jitter 101 | if b.randomization != 0 { 102 | min := next - b.randomization*next 103 | max := next + b.randomization*next 104 | 105 | next = min + (rand.Float64() * (max - min)) // nolint:gosec 106 | } 107 | 108 | nextInterval := time.Duration(next) 109 | 110 | // Use the initial interval as the lower bounds 111 | if nextInterval < b.initialInterval { 112 | return b.initialInterval 113 | } 114 | 115 | // limit the upper bounds 116 | if nextInterval > b.maxInterval { 117 | return b.maxInterval 118 | } 119 | 120 | return nextInterval 121 | } 122 | -------------------------------------------------------------------------------- /retry/backoff_options.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // BackoffOption options for Backoff 8 | type BackoffOption func(*Backoff) 9 | 10 | // WithBackoffInitialInterval sets the initialInterval for Backoff 11 | func WithBackoffInitialInterval(initialInterval time.Duration) BackoffOption { 12 | return func(backoff *Backoff) { 13 | backoff.initialInterval = initialInterval 14 | } 15 | } 16 | 17 | // WithBackoffMaxRetries sets the maximum number of retries for Backoff 18 | func WithBackoffMaxRetries(maxRetries int) BackoffOption { 19 | return func(backoff *Backoff) { 20 | backoff.maxRetries = maxRetries 21 | } 22 | } 23 | 24 | // WithBackoffMaxInterval sets the maximum interval duration for Backoff 25 | func WithBackoffMaxInterval(maxInterval time.Duration) BackoffOption { 26 | return func(backoff *Backoff) { 27 | backoff.maxInterval = maxInterval 28 | } 29 | } 30 | 31 | // WithBackoffMultiplier sets the interval duration multipler for Backoff 32 | func WithBackoffMultiplier(multiplier float64) BackoffOption { 33 | return func(backoff *Backoff) { 34 | backoff.multiplier = multiplier 35 | } 36 | } 37 | 38 | // WithBackoffRandomizationFactor sets the randomization factor (min and max jigger) for Backoff 39 | func WithBackoffRandomizationFactor(randomizationFactor float64) BackoffOption { 40 | return func(backoff *Backoff) { 41 | backoff.randomization = randomizationFactor 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /retry/constant_backoff.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | // NewConstantBackoff constructs a Backoff strategy with a constant backoff retry rate 4 | func NewConstantBackoff(options ...BackoffOption) *Backoff { 5 | // Set some defaults for constant backoff 6 | conOptions := append(options, WithBackoffMultiplier(1), WithBackoffRandomizationFactor(0)) 7 | 8 | b := NewBackoff(conOptions...) 9 | 10 | return b 11 | } 12 | -------------------------------------------------------------------------------- /retry/constants.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Retry constants 8 | const ( 9 | DefaultInitialInterval = 500 * time.Millisecond 10 | DefaultMaxRetries = 100 11 | DefaultMaxInterval = 60 * time.Second 12 | 13 | DefaultMultiplier float64 = 1.5 14 | DefaultRandomizationFactor float64 = 0.5 15 | ) 16 | -------------------------------------------------------------------------------- /retry/errors.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | // Error constant texts 4 | const ( 5 | CannotBeRetried = "this operation cannot be retried" 6 | MaxRetriesExceeded = "this operation exceeded the maximum number of retries" 7 | MaxElapsedExceeded = "this operation exceeded the maximum time allowed to complete" 8 | ) 9 | 10 | // ErrDoNotRetry is used to wrap errors from retried calls that shouldn't be retried 11 | type ErrDoNotRetry struct { 12 | err error 13 | } 14 | 15 | // DoNotRetry wraps an error with ErrDoNotRetry so that it won't be retried by a Retryer 16 | func DoNotRetry(err error) error { 17 | return &ErrDoNotRetry{err: err} 18 | } 19 | 20 | func (e *ErrDoNotRetry) Error() string { 21 | return e.err.Error() 22 | } 23 | 24 | func (e *ErrDoNotRetry) Unwrap() error { 25 | return e.err 26 | } 27 | -------------------------------------------------------------------------------- /retry/exponential_backoff.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | // NewExponentialBackoff constructs a Backoff strategy with a exponential backoff retry rate 4 | func NewExponentialBackoff(options ...BackoffOption) *Backoff { 5 | // Set some defaults for exponential backoff 6 | expOptions := append([]BackoffOption{WithBackoffMultiplier(DefaultMultiplier)}, options...) 7 | 8 | b := NewBackoff(expOptions...) 9 | 10 | if b.multiplier <= 1.0 { 11 | panic("backoff: multiplier must be greater than 1 for exponential backoffs") 12 | } 13 | 14 | return b 15 | } 16 | -------------------------------------------------------------------------------- /retry/retryer.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Retryer interface 8 | type Retryer interface { 9 | Retry(ctx context.Context, fn func() error) error 10 | } 11 | -------------------------------------------------------------------------------- /saga/command.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | "github.com/stackus/edat/msg" 6 | ) 7 | 8 | // Command is a core.Command with message header information 9 | type Command interface { 10 | SagaID() string 11 | SagaName() string 12 | Command() core.Command 13 | Headers() msg.Headers 14 | } 15 | 16 | type commandMessage struct { 17 | sagaID string 18 | sagaName string 19 | command core.Command 20 | headers msg.Headers 21 | } 22 | 23 | func (m commandMessage) SagaID() string { 24 | return m.sagaID 25 | } 26 | 27 | func (m commandMessage) SagaName() string { 28 | return m.sagaName 29 | } 30 | 31 | func (m commandMessage) Command() core.Command { 32 | return m.command 33 | } 34 | 35 | func (m commandMessage) Headers() msg.Headers { 36 | return m.headers 37 | } 38 | -------------------------------------------------------------------------------- /saga/command_dispatcher.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/stackus/edat/core" 8 | "github.com/stackus/edat/log" 9 | "github.com/stackus/edat/msg" 10 | ) 11 | 12 | // CommandHandlerFunc function handlers for saga.Command 13 | type CommandHandlerFunc func(context.Context, Command) ([]msg.Reply, error) 14 | 15 | // CommandDispatcher is a MessageReceiver for Commands 16 | type CommandDispatcher struct { 17 | publisher msg.ReplyMessagePublisher 18 | handlers map[string]CommandHandlerFunc 19 | logger log.Logger 20 | } 21 | 22 | var _ msg.MessageReceiver = (*CommandDispatcher)(nil) 23 | 24 | // NewCommandDispatcher constructs a new CommandDispatcher 25 | func NewCommandDispatcher(publisher msg.ReplyMessagePublisher, options ...CommandDispatcherOption) *CommandDispatcher { 26 | c := &CommandDispatcher{ 27 | publisher: publisher, 28 | handlers: map[string]CommandHandlerFunc{}, 29 | logger: log.DefaultLogger, 30 | } 31 | 32 | for _, option := range options { 33 | option(c) 34 | } 35 | 36 | c.logger.Trace("saga.CommandDispatcher constructed") 37 | 38 | return c 39 | } 40 | 41 | // Handle adds a new Command that will be handled by handler 42 | func (d *CommandDispatcher) Handle(cmd core.Command, handler CommandHandlerFunc) *CommandDispatcher { 43 | d.logger.Trace("saga command handler added", log.String("CommandName", cmd.CommandName())) 44 | d.handlers[cmd.CommandName()] = handler 45 | return d 46 | } 47 | 48 | // ReceiveMessage implements MessageReceiver.ReceiveMessage 49 | func (d *CommandDispatcher) ReceiveMessage(ctx context.Context, message msg.Message) error { 50 | commandName, sagaID, sagaName, err := d.commandMessageInfo(message) 51 | if err != nil { 52 | return nil 53 | } 54 | 55 | logger := d.logger.Sub( 56 | log.String("CommandName", commandName), 57 | log.String("SagaName", sagaName), 58 | log.String("SagaID", sagaID), 59 | log.String("MessageID", message.ID()), 60 | ) 61 | 62 | logger.Debug("received saga command message") 63 | 64 | // check first for a handler of the command; It is possible commands might be published into channels 65 | // that haven't been registered in our application 66 | handler, exists := d.handlers[commandName] 67 | if !exists { 68 | return nil 69 | } 70 | 71 | logger.Trace("saga command handler found") 72 | 73 | command, err := core.DeserializeCommand(commandName, message.Payload()) 74 | if err != nil { 75 | logger.Error("error decoding saga command message payload", log.Error(err)) 76 | return nil 77 | } 78 | 79 | replyChannel, err := message.Headers().GetRequired(msg.MessageCommandReplyChannel) 80 | if err != nil { 81 | logger.Error("error reading reply channel", log.Error(err)) 82 | return nil 83 | } 84 | 85 | correlationHeaders := d.correlationHeaders(message.Headers()) 86 | 87 | cmdMsg := commandMessage{sagaID, sagaName, command, correlationHeaders} 88 | 89 | replies, err := handler(ctx, cmdMsg) 90 | if err != nil { 91 | logger.Error("saga command handler returned an error", log.Error(err)) 92 | rerr := d.sendReplies(ctx, replyChannel, []msg.Reply{msg.WithFailure()}, correlationHeaders) 93 | if rerr != nil { 94 | logger.Error("error sending replies", log.Error(rerr)) 95 | return rerr 96 | } 97 | return nil 98 | } 99 | 100 | err = d.sendReplies(ctx, replyChannel, replies, correlationHeaders) 101 | if err != nil { 102 | logger.Error("error sending replies", log.Error(err)) 103 | return err 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func (d *CommandDispatcher) commandMessageInfo(message msg.Message) (string, string, string, error) { 110 | var err error 111 | var commandName, sagaID, sagaName string 112 | 113 | commandName, err = message.Headers().GetRequired(msg.MessageCommandName) 114 | if err != nil { 115 | d.logger.Error("error reading command name", log.Error(err)) 116 | return "", "", "", err 117 | } 118 | 119 | sagaID, err = message.Headers().GetRequired(MessageCommandSagaID) 120 | if err != nil { 121 | d.logger.Error("error reading saga id", log.Error(err)) 122 | return "", "", "", err 123 | } 124 | 125 | sagaName, err = message.Headers().GetRequired(MessageCommandSagaName) 126 | if err != nil { 127 | d.logger.Error("error reading saga name", log.Error(err)) 128 | return "", "", "", err 129 | } 130 | 131 | return commandName, sagaID, sagaName, nil 132 | } 133 | 134 | func (d *CommandDispatcher) sendReplies(ctx context.Context, replyChannel string, replies []msg.Reply, correlationHeaders msg.Headers) error { 135 | for _, reply := range replies { 136 | if err := d.publisher.PublishReply(ctx, reply.Reply(), 137 | msg.WithHeaders(correlationHeaders), 138 | msg.WithHeaders(reply.Headers()), 139 | msg.WithDestinationChannel(replyChannel), 140 | ); err != nil { 141 | return err 142 | } 143 | } 144 | 145 | return nil 146 | } 147 | 148 | func (d *CommandDispatcher) correlationHeaders(headers msg.Headers) msg.Headers { 149 | replyHeaders := make(map[string]string) 150 | for key, value := range headers { 151 | if key == msg.MessageCommandName { 152 | continue 153 | } 154 | 155 | if strings.HasPrefix(key, msg.MessageCommandPrefix) { 156 | replyHeader := msg.MessageReplyPrefix + key[len(msg.MessageCommandPrefix):] 157 | replyHeaders[replyHeader] = value 158 | } 159 | } 160 | 161 | return replyHeaders 162 | } 163 | -------------------------------------------------------------------------------- /saga/command_dispatcher_options.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // CommandDispatcherOption options for CommandConsumers 8 | type CommandDispatcherOption func(consumer *CommandDispatcher) 9 | 10 | // WithCommandDispatcherLogger is an option to set the log.Logger of the CommandDispatcher 11 | func WithCommandDispatcherLogger(logger log.Logger) CommandDispatcherOption { 12 | return func(dispatcher *CommandDispatcher) { 13 | dispatcher.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /saga/constants.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/msg" 5 | ) 6 | 7 | const ( 8 | notCompensating = false 9 | isCompensating = true 10 | ) 11 | 12 | // LifecycleHook type for hooking in custom code at specific stages of a saga 13 | type LifecycleHook int 14 | 15 | // Definition lifecycle hooks 16 | const ( 17 | SagaStarting LifecycleHook = iota 18 | SagaCompleted 19 | SagaCompensated 20 | ) 21 | 22 | // Saga message headers 23 | const ( 24 | MessageCommandSagaID = msg.MessageCommandPrefix + "SAGA_ID" 25 | MessageCommandSagaName = msg.MessageCommandPrefix + "SAGA_NAME" 26 | MessageCommandResource = msg.MessageCommandPrefix + "RESOURCE" 27 | 28 | MessageReplySagaID = msg.MessageReplyPrefix + "SAGA_ID" 29 | MessageReplySagaName = msg.MessageReplyPrefix + "SAGA_NAME" 30 | ) 31 | -------------------------------------------------------------------------------- /saga/definition.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | // Definition interface 4 | type Definition interface { 5 | SagaName() string 6 | ReplyChannel() string 7 | Steps() []Step 8 | OnHook(hook LifecycleHook, instance *Instance) 9 | } 10 | -------------------------------------------------------------------------------- /saga/instance.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | ) 6 | 7 | // Instance is the container for saga data 8 | type Instance struct { 9 | sagaID string 10 | sagaName string 11 | sagaData core.SagaData 12 | currentStep int 13 | endState bool 14 | compensating bool 15 | } 16 | 17 | // NewSagaInstance constructor for *SagaInstances 18 | func NewSagaInstance(sagaName, sagaID string, sagaData core.SagaData, currentStep int, endState, compensating bool) *Instance { 19 | return &Instance{ 20 | sagaID: sagaID, 21 | sagaName: sagaName, 22 | sagaData: sagaData, 23 | currentStep: currentStep, 24 | endState: endState, 25 | compensating: compensating, 26 | } 27 | } 28 | 29 | // SagaID returns the instance saga id 30 | func (i *Instance) SagaID() string { 31 | return i.sagaID 32 | } 33 | 34 | // SagaName returns the instance saga name 35 | func (i *Instance) SagaName() string { 36 | return i.sagaName 37 | } 38 | 39 | // SagaData returns the instance saga data 40 | func (i *Instance) SagaData() core.SagaData { 41 | return i.sagaData 42 | } 43 | 44 | // CurrentStep returns the step currently being processed 45 | func (i *Instance) CurrentStep() int { 46 | return i.currentStep 47 | } 48 | 49 | // EndState returns whether or not all steps have completed 50 | func (i *Instance) EndState() bool { 51 | return i.endState 52 | } 53 | 54 | // Compensating returns whether or not the instance is compensating (rolling back) 55 | func (i *Instance) Compensating() bool { 56 | return i.compensating 57 | } 58 | 59 | func (i *Instance) getStepContext() stepContext { 60 | return stepContext{ 61 | step: i.currentStep, 62 | compensating: i.compensating, 63 | ended: i.endState, 64 | } 65 | } 66 | 67 | func (i *Instance) updateStepContext(stepCtx stepContext) { 68 | i.currentStep = stepCtx.step 69 | i.endState = stepCtx.ended 70 | i.compensating = stepCtx.compensating 71 | } 72 | -------------------------------------------------------------------------------- /saga/instance_store.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // InstanceStore interface 8 | type InstanceStore interface { 9 | Find(ctx context.Context, sagaName, sagaID string) (*Instance, error) 10 | Save(ctx context.Context, sagaInstance *Instance) error 11 | Update(ctx context.Context, sagaInstance *Instance) error 12 | } 13 | -------------------------------------------------------------------------------- /saga/local_step.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | ) 8 | 9 | // LocalStep is used to execute local saga business logic 10 | type LocalStep struct { 11 | actions map[bool]func(context.Context, core.SagaData) error 12 | } 13 | 14 | var _ Step = (*LocalStep)(nil) 15 | 16 | // NewLocalStep constructor for LocalStep 17 | func NewLocalStep(action func(context.Context, core.SagaData) error) LocalStep { 18 | return LocalStep{ 19 | actions: map[bool]func(context.Context, core.SagaData) error{ 20 | notCompensating: action, 21 | }, 22 | } 23 | } 24 | 25 | // Compensation sets the compensating action for this step 26 | func (s LocalStep) Compensation(compensation func(context.Context, core.SagaData) error) LocalStep { 27 | s.actions[isCompensating] = compensation 28 | return s 29 | } 30 | 31 | func (s LocalStep) hasInvocableAction(_ context.Context, _ core.SagaData, compensating bool) bool { 32 | return s.actions[compensating] != nil 33 | } 34 | 35 | func (s LocalStep) getReplyHandler(string, bool) func(context.Context, core.SagaData, core.Reply) error { 36 | return nil 37 | } 38 | 39 | func (s LocalStep) execute(ctx context.Context, sagaData core.SagaData, compensating bool) func(results *stepResults) { 40 | err := s.actions[compensating](ctx, sagaData) 41 | return func(results *stepResults) { 42 | results.local = true 43 | results.failure = err 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /saga/message_options.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/msg" 5 | ) 6 | 7 | // WithSagaInfo is an option to set additional Saga specific headers 8 | func WithSagaInfo(instance *Instance) msg.MessageOption { 9 | return msg.WithHeaders(map[string]string{ 10 | MessageCommandSagaID: instance.sagaID, 11 | MessageCommandSagaName: instance.sagaName, 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /saga/orchestrator_options.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/log" 5 | ) 6 | 7 | // OrchestratorOption options for Orchestrator 8 | type OrchestratorOption func(o *Orchestrator) 9 | 10 | // WithOrchestratorLogger is an option to set the log.Logger of the Orchestrator 11 | func WithOrchestratorLogger(logger log.Logger) OrchestratorOption { 12 | return func(o *Orchestrator) { 13 | o.logger = logger 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /saga/remote_step.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | "github.com/stackus/edat/msg" 8 | ) 9 | 10 | // RemoteStep is used to execute distributed saga business logic 11 | type RemoteStep struct { 12 | actionHandlers map[bool]*remoteStepAction 13 | replyHandlers map[bool]map[string]func(context.Context, core.SagaData, core.Reply) error 14 | } 15 | 16 | var _ Step = (*RemoteStep)(nil) 17 | 18 | // NewRemoteStep constructor for RemoteStep 19 | func NewRemoteStep() RemoteStep { 20 | return RemoteStep{ 21 | actionHandlers: map[bool]*remoteStepAction{ 22 | notCompensating: nil, 23 | isCompensating: nil, 24 | }, 25 | replyHandlers: map[bool]map[string]func(context.Context, core.SagaData, core.Reply) error{ 26 | notCompensating: {}, 27 | isCompensating: {}, 28 | }, 29 | } 30 | } 31 | 32 | // Action adds a domain command constructor that will be called while the definition is advancing 33 | func (s RemoteStep) Action(fn func(context.Context, core.SagaData) msg.DomainCommand, options ...RemoteStepActionOption) RemoteStep { 34 | handler := &remoteStepAction{ 35 | handler: fn, 36 | } 37 | 38 | for _, option := range options { 39 | option(handler) 40 | } 41 | 42 | s.actionHandlers[notCompensating] = handler 43 | 44 | return s 45 | } 46 | 47 | // HandleActionReply adds additional handling for specific replies while advancing 48 | // 49 | // SuccessReply and FailureReply do not require any special handling unless desired 50 | func (s RemoteStep) HandleActionReply(reply core.Reply, handler func(context.Context, core.SagaData, core.Reply) error) RemoteStep { 51 | s.replyHandlers[notCompensating][reply.ReplyName()] = handler 52 | 53 | return s 54 | } 55 | 56 | // Compensation adds a domain command constructor that will be called while the definition is compensating 57 | func (s RemoteStep) Compensation(fn func(context.Context, core.SagaData) msg.DomainCommand, options ...RemoteStepActionOption) RemoteStep { 58 | handler := &remoteStepAction{ 59 | handler: fn, 60 | } 61 | 62 | for _, option := range options { 63 | option(handler) 64 | } 65 | 66 | s.actionHandlers[isCompensating] = handler 67 | 68 | return s 69 | } 70 | 71 | // HandleCompensationReply adds additional handling for specific replies while compensating 72 | // 73 | // SuccessReply does not require any special handling unless desired 74 | func (s RemoteStep) HandleCompensationReply(reply core.Reply, handler func(context.Context, core.SagaData, core.Reply) error) RemoteStep { 75 | s.replyHandlers[isCompensating][reply.ReplyName()] = handler 76 | 77 | return s 78 | } 79 | 80 | func (s RemoteStep) hasInvocableAction(ctx context.Context, sagaData core.SagaData, compensating bool) bool { 81 | return s.actionHandlers[compensating] != nil && s.actionHandlers[compensating].isInvocable(ctx, sagaData) 82 | } 83 | 84 | func (s RemoteStep) getReplyHandler(replyName string, compensating bool) func(context.Context, core.SagaData, core.Reply) error { 85 | return s.replyHandlers[compensating][replyName] 86 | } 87 | 88 | func (s RemoteStep) execute(ctx context.Context, sagaData core.SagaData, compensating bool) func(results *stepResults) { 89 | if commandToSend := s.actionHandlers[compensating].execute(ctx, sagaData); commandToSend != nil { 90 | return func(actions *stepResults) { 91 | actions.commands = []msg.DomainCommand{commandToSend} 92 | } 93 | } 94 | 95 | return func(actions *stepResults) {} 96 | } 97 | -------------------------------------------------------------------------------- /saga/remote_step_action.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | "github.com/stackus/edat/msg" 8 | ) 9 | 10 | type remoteStepAction struct { 11 | predicate func(context.Context, core.SagaData) bool 12 | handler func(context.Context, core.SagaData) msg.DomainCommand 13 | } 14 | 15 | func (a *remoteStepAction) isInvocable(ctx context.Context, sagaData core.SagaData) bool { 16 | if a.predicate == nil { 17 | return true 18 | } 19 | 20 | return a.predicate(ctx, sagaData) 21 | } 22 | 23 | func (a *remoteStepAction) execute(ctx context.Context, sagaData core.SagaData) msg.DomainCommand { 24 | return a.handler(ctx, sagaData) 25 | } 26 | -------------------------------------------------------------------------------- /saga/remote_step_action_options.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | ) 8 | 9 | // RemoteStepActionOption options for remoteStepAction 10 | type RemoteStepActionOption func(action *remoteStepAction) 11 | 12 | // WithRemoteStepPredicate sets a predicate function for the action 13 | func WithRemoteStepPredicate(predicate func(context.Context, core.SagaData) bool) RemoteStepActionOption { 14 | return func(step *remoteStepAction) { 15 | step.predicate = predicate 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /saga/step.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stackus/edat/core" 7 | ) 8 | 9 | // Step interface for local, remote, ...other saga steps 10 | type Step interface { 11 | hasInvocableAction(ctx context.Context, sagaData core.SagaData, compensating bool) bool 12 | getReplyHandler(replyName string, compensating bool) func(ctx context.Context, data core.SagaData, reply core.Reply) error 13 | execute(ctx context.Context, sagaData core.SagaData, compensating bool) func(results *stepResults) 14 | } 15 | -------------------------------------------------------------------------------- /saga/step_context.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | type stepContext struct { 4 | step int 5 | compensating bool 6 | ended bool 7 | } 8 | 9 | func (s *stepContext) next(stepIndex int) stepContext { 10 | if s.compensating { 11 | return stepContext{step: s.step - stepIndex, compensating: s.compensating} 12 | } 13 | 14 | return stepContext{step: s.step + stepIndex, compensating: s.compensating} 15 | } 16 | 17 | func (s *stepContext) compensate() stepContext { 18 | return stepContext{step: s.step, compensating: true, ended: s.ended} 19 | } 20 | 21 | func (s *stepContext) end() stepContext { 22 | return stepContext{step: s.step, compensating: s.compensating, ended: true} 23 | } 24 | -------------------------------------------------------------------------------- /saga/step_results.go: -------------------------------------------------------------------------------- 1 | package saga 2 | 3 | import ( 4 | "github.com/stackus/edat/core" 5 | "github.com/stackus/edat/msg" 6 | ) 7 | 8 | type stepResults struct { 9 | commands []msg.DomainCommand 10 | updatedSagaData core.SagaData 11 | updatedStepContext stepContext 12 | local bool 13 | failure error 14 | } 15 | --------------------------------------------------------------------------------