├── .gitignore ├── README.md ├── application.go ├── application_test.go ├── clock.go ├── command.go ├── doc.go ├── event.go ├── event_store_test.go ├── event_store_test_suite.go ├── event_test.go ├── events_in_memory.go ├── events_on_disk.go ├── examples ├── blog │ ├── all_posts.go │ ├── html.go │ ├── main.go │ ├── post.go │ ├── sessions.go │ ├── templates.go │ └── user.go └── signup │ ├── main.go │ └── main_test.go ├── interfaces.go ├── test_aggregate.go ├── validation_error.go ├── validation_error_test.go └── values.go /.gitignore: -------------------------------------------------------------------------------- 1 | /events.json 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | This package provides a library for building event sourced systems. 4 | See the [documentation](http://godoc.org/github.com/dhamidi/ess) for 5 | what this means. 6 | 7 | # License 8 | 9 | Copyright (c) 2015, Dario Hamidi 10 | All rights reserved. 11 | 12 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 13 | 14 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 15 | 16 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 17 | 18 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /application.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | // Application represents an event sourced application. 9 | // 10 | // Any interaction with an application happens by sending it commands. 11 | // 12 | // Commands are messages expressing user intent and lead to changes of 13 | // the application state. Every command is routed to an object 14 | // handling the application's business logic, called an Aggregate. 15 | // 16 | // Processing a command by an aggregate either fails or produces 17 | // events. An event records a state change. The application appends 18 | // all events produced in this manner to an append-only log, the 19 | // EventStore. 20 | // 21 | // Every time a command is processed, the object handling the command 22 | // is passed all the previous events that it emitted, so that it can 23 | // reconstruct any internal state necessary for it to function. 24 | // 25 | // If a command has been processed successfully and emitted events 26 | // have been stored, all events are passed to the projections 27 | // registered with the application. 28 | // 29 | // A projection accepts events and produces a secondary data model 30 | // which is used for querying and represents the current application 31 | // state. Multiple such models can exist in parallel. By using 32 | // projections an application can maintain models that are optimized 33 | // for serving a specific use case. Examples range from regenerating 34 | // static files over maintaining a normalized relational database to 35 | // updating a search index. 36 | // 37 | // When the application starts the whole history is replayed through 38 | // all projections. This restricts projections to idempotent 39 | // operations. 40 | type Application struct { 41 | name string 42 | clock Clock 43 | store EventStore 44 | logger *log.Logger 45 | projections map[string]EventHandler 46 | } 47 | 48 | // NewApplication creates a new application instance with reasonable 49 | // default settings. Events are stored in memory only and 50 | // informational messages are logged to standard error. 51 | func NewApplication(name string) *Application { 52 | return &Application{ 53 | name: name, 54 | logger: log.New(os.Stderr, name+" ", log.LstdFlags), 55 | store: NewEventsInMemory(), 56 | clock: SystemClock, 57 | projections: map[string]EventHandler{}, 58 | } 59 | } 60 | 61 | // WithLogger sets the application's logger to logger. 62 | func (self *Application) WithLogger(logger *log.Logger) *Application { 63 | self.logger = logger 64 | return self 65 | } 66 | 67 | // WithStore sets the application's event store to store. Do not call 68 | // this method after Init has been called. 69 | func (self *Application) WithStore(store EventStore) *Application { 70 | self.store = store 71 | return self 72 | } 73 | 74 | // WithProjection registers projection with name at the application. 75 | func (self *Application) WithProjection(name string, projection EventHandler) *Application { 76 | self.projections[name] = projection 77 | return self 78 | } 79 | 80 | // Project passes event to all of the application's projections. 81 | func (self *Application) Project(event *Event) { 82 | for name, handler := range self.projections { 83 | self.logger.Printf("PROJECT %s TO %s", event.Name, name) 84 | handler.HandleEvent(event) 85 | } 86 | } 87 | 88 | // Init reconstructs application state from history. Call this method 89 | // once initially after configuring your application. 90 | func (self *Application) Init() error { 91 | return self.store.Replay("*", EventHandlerFunc(self.Project)) 92 | } 93 | 94 | // Send sends command to the application for processing. Send is not 95 | // thread safe. 96 | func (self *Application) Send(command *Command) *CommandResult { 97 | command.Acknowledge(self.clock) 98 | 99 | receiver := command.Receiver() 100 | 101 | if err := self.store.Replay(receiver.Id(), receiver); err != nil { 102 | return NewErrorResult(err) 103 | } 104 | 105 | transaction := NewEventsInMemory() 106 | receiver.PublishWith(transaction) 107 | 108 | self.logger.Printf("EXECUTE %s", command) 109 | if err := command.Execute(); err != nil { 110 | self.logger.Printf("DENY %s", err) 111 | return NewErrorResult(err) 112 | } 113 | 114 | events := transaction.Events() 115 | for _, event := range events { 116 | event.Occur(self.clock) 117 | self.logger.Printf("EVENT %s", event.Name) 118 | } 119 | if err := self.store.Store(events); err != nil { 120 | return NewErrorResult(err) 121 | } 122 | 123 | for _, event := range events { 124 | self.Project(event) 125 | } 126 | 127 | return NewSuccessResult(receiver) 128 | } 129 | -------------------------------------------------------------------------------- /application_test.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | var ( 10 | TestCommand = NewCommandDefinition("test"). 11 | Field("param", TrimmedString()). 12 | Target(newTestAggregateFromCommand) 13 | 14 | TheTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) 15 | 16 | CurrentLines = []string{} 17 | ) 18 | 19 | type LineWriter struct { 20 | lines *[]string 21 | currentLine []byte 22 | } 23 | 24 | func NewLineWriter(lines *[]string) *LineWriter { 25 | return &LineWriter{ 26 | lines: lines, 27 | currentLine: []byte{}, 28 | } 29 | } 30 | 31 | func (self *LineWriter) Write(data []byte) (n int, err error) { 32 | for _, c := range data { 33 | if c == '\n' { 34 | *self.lines = append(*self.lines, string(self.currentLine)) 35 | self.currentLine = []byte{} 36 | } else { 37 | self.currentLine = append(self.currentLine, c) 38 | } 39 | } 40 | 41 | return len(data), nil 42 | } 43 | 44 | func NewTestApp() *Application { 45 | CurrentLines = []string{} 46 | app := NewApplication("test") 47 | app.clock = &StaticClock{TheTime} 48 | app.WithLogger(log.New(NewLineWriter(&CurrentLines), "test ", 0)) 49 | return app 50 | } 51 | 52 | func TestApplication_Send_acknowledgesCommand(t *testing.T) { 53 | app := NewTestApp() 54 | cmd := TestCommand.NewCommand() 55 | result := app.Send(cmd) 56 | 57 | if err := result.Error(); err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | if got, want := cmd.Get("now").(*Time).Time, TheTime; !got.Equal(want) { 62 | t.Errorf(`cmd.Get("now").(*Time).Time = %q; want %q`, got, want) 63 | } 64 | } 65 | 66 | func TestApplication_Send_replaysHistoryOnReceiver(t *testing.T) { 67 | app := NewTestApp() 68 | seen := 0 69 | other := newTestAggregate("other") 70 | receiver := newTestAggregate("test") 71 | receiver.onEvent = func(*Event) { seen++ } 72 | history := []*Event{ 73 | NewEvent("test.run").For(other), 74 | NewEvent("test.run").For(receiver), 75 | NewEvent("test.run").For(receiver), 76 | } 77 | app.store.Store(history) 78 | cmd := TestCommand.NewCommand() 79 | cmd.receiver = receiver 80 | result := app.Send(cmd) 81 | 82 | if err := result.Error(); err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | if got, want := seen, len(history)-1; got != want { 87 | t.Errorf("seen = %d; want %d", got, want) 88 | } 89 | } 90 | 91 | func TestApplication_Send_returnsErrorIfExecutingCommandFails(t *testing.T) { 92 | cmd := TestCommand.NewCommand() 93 | failure := NewValidationError().Add("param", "invalid") 94 | cmd.receiver = newTestAggregate("test").FailWith(failure.Return()) 95 | app := NewTestApp() 96 | result := app.Send(cmd) 97 | 98 | if err := result.Error(); err != failure { 99 | t.Errorf("result.Error() = %q; want %q", err, failure) 100 | } 101 | } 102 | 103 | func TestApplication_Send_marksOccurrenceOnEvents(t *testing.T) { 104 | app := NewTestApp() 105 | cmd := TestCommand.NewCommand() 106 | receiver := newTestAggregate("test") 107 | cmd.receiver = receiver 108 | event := NewEvent("test.run").For(cmd.receiver) 109 | receiver.onCommand = func(agg *testAggregate) { 110 | agg.events.PublishEvent(event) 111 | } 112 | 113 | result := app.Send(cmd) 114 | if err := result.Error(); err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | if got, want := event.OccurredOn, TheTime; !got.Equal(want) { 119 | t.Errorf("event.OccurredOn = %q; want %q", got, want) 120 | } 121 | } 122 | 123 | func TestApplication_Send_storesEvents(t *testing.T) { 124 | transaction := NewEventsInMemory() 125 | app := NewTestApp().WithStore(transaction) 126 | cmd := TestCommand.NewCommand() 127 | receiver := newTestAggregate("test") 128 | cmd.receiver = receiver 129 | event := NewEvent("test.run").For(cmd.receiver) 130 | receiver.onCommand = func(agg *testAggregate) { 131 | agg.events.PublishEvent(event) 132 | } 133 | 134 | result := app.Send(cmd) 135 | if err := result.Error(); err != nil { 136 | t.Fatal(err) 137 | } 138 | 139 | if got, want := transaction.Events()[0], event; got != want { 140 | t.Errorf("transaction.Events()[0] = %q; want %q", got, want) 141 | } 142 | } 143 | 144 | func TestApplication_Send_projectsEvents(t *testing.T) { 145 | projected := 0 146 | app := NewTestApp(). 147 | WithProjection("test", EventHandlerFunc(func(*Event) { 148 | projected++ 149 | })) 150 | cmd := TestCommand.NewCommand() 151 | receiver := newTestAggregate("test") 152 | cmd.receiver = receiver 153 | event := NewEvent("test.run").For(cmd.receiver) 154 | receiver.onCommand = func(agg *testAggregate) { 155 | agg.events.PublishEvent(event) 156 | } 157 | 158 | result := app.Send(cmd) 159 | if err := result.Error(); err != nil { 160 | t.Fatal(err) 161 | } 162 | 163 | if got, want := projected, 1; got != want { 164 | t.Errorf("projected = %d; want %d", got, want) 165 | } 166 | } 167 | 168 | func TestApplication_Init_replaysHistoryThroughProjections(t *testing.T) { 169 | seen := map[string]int{} 170 | store := NewEventsInMemory() 171 | history := []*Event{ 172 | NewEvent("test.event"), 173 | } 174 | store.Store(history) 175 | app := NewTestApp().WithStore(store). 176 | WithProjection( 177 | "a", 178 | EventHandlerFunc(func(*Event) { 179 | seen["a"]++ 180 | }), 181 | ). 182 | WithProjection( 183 | "b", 184 | EventHandlerFunc(func(*Event) { 185 | seen["b"]++ 186 | }), 187 | ) 188 | 189 | if err := app.Init(); err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | if got, want := seen["a"], len(history); got != want { 194 | t.Errorf(`seen["a"] = %d; want %d`, got, want) 195 | } 196 | 197 | if got, want := seen["b"], len(history); got != want { 198 | t.Errorf(`seen["b"] = %d; want %d`, got, want) 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /clock.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import "time" 4 | 5 | var ( 6 | // SystemClock provides access to time.Now through the Clock 7 | // interface. 8 | SystemClock = &SystemTime{} 9 | ) 10 | 11 | // SystemTime wraps time.Now to implement the Clock interface. 12 | type SystemTime struct{} 13 | 14 | func (self *SystemTime) Now() time.Time { 15 | return time.Now() 16 | } 17 | 18 | // StaticClock implements the Clock interface by returning a static 19 | // time. Its intended use is in test cases. 20 | type StaticClock struct { 21 | time.Time 22 | } 23 | 24 | func (self *StaticClock) Now() time.Time { 25 | return self.Time 26 | } 27 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | // CommandResult represents the result of the application handling a 9 | // command. 10 | type CommandResult struct { 11 | aggregateId string 12 | err error 13 | } 14 | 15 | // Error returns any error encountered or caused by processing the 16 | // command. If nil is returned, it is safe to assume that the 17 | // application processed the command successfully. 18 | func (self *CommandResult) Error() error { 19 | return self.err 20 | } 21 | 22 | // AggregateId returns the id of the command's receiver. 23 | func (self *CommandResult) AggregateId() string { 24 | return self.aggregateId 25 | } 26 | 27 | // NewErrorResult wraps err in a CommandResult. 28 | func NewErrorResult(err error) *CommandResult { 29 | return &CommandResult{ 30 | err: err, 31 | } 32 | } 33 | 34 | // NewSuccessResult returns a CommandResult that marks a success for 35 | // receiver. 36 | func NewSuccessResult(receiver Aggregate) *CommandResult { 37 | return &CommandResult{ 38 | aggregateId: receiver.Id(), 39 | } 40 | } 41 | 42 | // CommandDefinition is used for defining the commands accepted by the 43 | // application. Essentially it is a dynamically built definition of 44 | // messages the system accepts. 45 | // 46 | // New command instances are created from an existing command 47 | // definition. 48 | // 49 | // The following is a complete example of defining a command your 50 | // application accepts: 51 | // 52 | // SignUp = ess.NewCommandDefinition("sign-up"). 53 | // Id("username", ess.Id()). 54 | // Field("email", ess.EmailAddress()). 55 | // Field("password", ess.Password()). 56 | // Target(UserFromCommand) 57 | // 58 | // func UserFromCommand(command *Command) Aggregate { 59 | // return NewUser(command.Get("username").String()) 60 | // } 61 | // 62 | // New command instances can then be created from this definition: 63 | // 64 | // signUp := SignUp.NewCommand().Set("username", "admin") /* ... */ 65 | // 66 | // A convenience method is provided to set parameters based on a HTTP 67 | // request object: 68 | // 69 | // signUp := SignUp.FromForm(req) 70 | type CommandDefinition struct { 71 | Name string // name of the command, e.g. "sign-up" 72 | Fields map[string]Value // map of parameter name to accepted type 73 | 74 | // TargetFunc constructs a new instance of the receiver of the 75 | // command. This callback exists to avoid the use of 76 | // reflection. 77 | TargetFunc func(*Command) Aggregate 78 | 79 | // IdField is the name of the parameter which identifies the 80 | // command receiver, defaults to "id" 81 | IdField string 82 | } 83 | 84 | // NewCommandDefinition creates a new command definition using name as 85 | // the name for the command. 86 | func NewCommandDefinition(name string) *CommandDefinition { 87 | return &CommandDefinition{ 88 | Name: name, 89 | Fields: map[string]Value{}, 90 | IdField: "id", 91 | } 92 | } 93 | 94 | // Id sets the name and type of the field that is considered identify 95 | // the command's receiver. 96 | // 97 | // The default is to use a field named "id" of type "Identifier". 98 | func (self *CommandDefinition) Id(name string, value Value) *CommandDefinition { 99 | self.IdField = name 100 | return self.Field(name, value) 101 | } 102 | 103 | // Field defines a field with the given name and type. Use this 104 | // method to define the different parameters of a command. 105 | func (self *CommandDefinition) Field(name string, value Value) *CommandDefinition { 106 | self.Fields[name] = value 107 | return self 108 | } 109 | 110 | // Target sets the function to create a new receiver of the right type 111 | // for this command to constructor. 112 | // 113 | // The constructor's task is to return an aggregate instance with the 114 | // appropriate ID extracted from the command passed to the 115 | // constructor. 116 | // 117 | // Example: 118 | // 119 | // func UserFromCommand(command *Command) Aggregate { 120 | // return NewUser(command.Get("username").String()) 121 | // } 122 | func (self *CommandDefinition) Target(constructor func(*Command) Aggregate) *CommandDefinition { 123 | self.TargetFunc = constructor 124 | return self 125 | } 126 | 127 | // NewCommand constructs a new instance of a command, according to 128 | // this command definition. 129 | func (self *CommandDefinition) NewCommand() *Command { 130 | cmd := &Command{ 131 | Name: self.Name, 132 | Fields: map[string]Value{ 133 | self.IdField: Id(), 134 | }, 135 | IdField: self.IdField, 136 | errors: NewValidationError(), 137 | receiverFunc: self.TargetFunc, 138 | } 139 | 140 | for field, val := range self.Fields { 141 | cmd.Fields[field] = val.Copy() 142 | } 143 | 144 | return cmd 145 | } 146 | 147 | // FromForm is a convenience method to create a new command instance 148 | // and populate immediately from form. 149 | func (self *CommandDefinition) FromForm(form Form) *Command { 150 | command := self.NewCommand() 151 | return command.FromForm(form) 152 | } 153 | 154 | // Command represents a message sent to your application with the 155 | // intention to change application state. 156 | // 157 | // Commands are named in the imperative, e.g. "sign-up" or 158 | // "place-order". A command is targetted at a single receiver, the so 159 | // called aggregate. 160 | // 161 | // The fields of a command are of type Value to provide a uniform 162 | // interface for sanitizing inputs. 163 | type Command struct { 164 | Name string 165 | Fields map[string]Value 166 | IdField string 167 | 168 | errors *ValidationError 169 | receiver Aggregate 170 | receiverFunc func(*Command) Aggregate 171 | } 172 | 173 | // AggregateId returns the id of the command's receiver, according to 174 | // the command's IdField. If the field is not present, it returns the 175 | // empty string. 176 | func (self *Command) AggregateId() string { 177 | val := self.Get(self.IdField) 178 | if val != nil { 179 | return val.String() 180 | } else { 181 | return "" 182 | } 183 | } 184 | 185 | // err adds an error to the list of errors for field 186 | func (self *Command) err(field string, err error) { 187 | self.errors.Add(field, err.Error()) 188 | } 189 | 190 | // Get returns the field identified by name or nil if the field does 191 | // not exist. 192 | func (self *Command) Get(name string) Value { 193 | return self.Fields[name] 194 | } 195 | 196 | // Receiver returns an instance of the command's receiver, possibly 197 | // creating the instance. 198 | func (self *Command) Receiver() Aggregate { 199 | if self.receiver == nil { 200 | self.receiver = self.receiverFunc(self) 201 | } 202 | 203 | return self.receiver 204 | } 205 | 206 | // Set sets the value for the field identified by name. Setting a 207 | // value using this method parses the string given in value according 208 | // to the field's type and remembers any errors encountered. 209 | // 210 | // Use this method to "fill in" the parameters of a command. 211 | func (self *Command) Set(name string, value string) *Command { 212 | target, found := self.Fields[name] 213 | if found { 214 | err := target.UnmarshalText([]byte(value)) 215 | if err != nil { 216 | self.err(name, err) 217 | } 218 | } 219 | 220 | return self 221 | } 222 | 223 | // FromForm sets all of the command's fields with the values found in 224 | // form. 225 | func (self *Command) FromForm(form Form) *Command { 226 | for field, value := range self.Fields { 227 | text := form.FormValue(field) 228 | if err := value.UnmarshalText([]byte(text)); err != nil { 229 | self.err(field, err) 230 | } 231 | } 232 | 233 | return self 234 | } 235 | 236 | // Acknowledge marks the command as having been received by the 237 | // system. 238 | // 239 | // Calling Acknowledge on a command sets the field "now" to the time 240 | // provided by clock. 241 | // 242 | // This is useful for recording the time of actions in published 243 | // events. 244 | func (self *Command) Acknowledge(clock Clock) { 245 | now := clock.Now() 246 | self.Fields["now"] = &Time{now} 247 | } 248 | 249 | // Execute passes this command to its receiver, merging any errors 250 | // returned into the errors encountered during parameter processing. 251 | func (self *Command) Execute() error { 252 | err := self.receiver.HandleCommand(self) 253 | 254 | if !self.errors.Ok() { 255 | return self.errors.Merge(err).Return() 256 | } 257 | 258 | return err 259 | } 260 | 261 | // String returns a multiline representation of the command. 262 | // 263 | // The information contained in the returned string is enough to 264 | // reconstruct the command. 265 | func (self *Command) String() string { 266 | out := bytes.NewBufferString(self.Name + "\n") 267 | 268 | for field, value := range self.Fields { 269 | fmt.Fprintf(out, "param %s: ", field) 270 | fmt.Fprintf(out, "%q", value) 271 | fmt.Fprintf(out, "\n") 272 | } 273 | 274 | return out.String() 275 | } 276 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This package provides tools for implementing an application using 4 | event sourcing. For a detailed explanation of event sourcing, see 5 | Martin Fowler's article on the topic: 6 | http://martinfowler.com/eaaDev/EventNarrative.html#EventSourcing. 7 | 8 | Short summary of event sourcing 9 | 10 | Event sourcing works by capturing every state change to the system in 11 | the form of events and logging these events to usually persistent 12 | storage. The current state of the system can be computed by going 13 | through the event history. 14 | 15 | Queries are served from separate models targeted at specific use 16 | cases (projections). These models are updated by listening to events as they are 17 | appended to the event history. Multiple such models can exist next to 18 | each other: a search index, a relational database, static files. 19 | 20 | Event sourcing as implemented by this package 21 | 22 | The following sketch gives an overview about the dataflow and 23 | components in applications built with this package: 24 | 25 | 26 | Performing writes: 27 | Client ---> 28 | Command ---> 29 | Application ---> 30 | Domain Object ---> 31 | Event History ---> Projections 32 | 33 | Performing reads: 34 | Client <--- Projections 35 | 36 | 37 | A client can be anything that is capable of sending commands to the 38 | application instance over any transport mechanism. In most cases the 39 | client will be a web browser sending commands over HTTP in the form of 40 | POST requests. Other likely clients include a command line interface 41 | for performing administrative tasks. 42 | 43 | A command is a message sent to the application with the intent of 44 | changing application state. Commands map directly to actions intended 45 | by the user, e.g. "sign-up" or "login". Every command sent to the 46 | application is targeted a specific domain object. The "login" command 47 | for example would be targeted at a specific user, the one who is 48 | logging in. 49 | 50 | The application processes a command by first identifying and 51 | instantiating the command's receiver. It then replays any historic 52 | events for the receiver, in order for the receiver to reconstruct any 53 | necessary internal state. If the reconstructing the receiver's 54 | current state caused no problem, the command is passed to the receiver 55 | to handle. 56 | 57 | The domain object is where your business logic lives. Domain objects 58 | act as command receivers and enforce business rules. If the domain 59 | object accepts a command, it emits events to make note of this fact. 60 | Otherwise the command is rejected and an error is reported to the 61 | client. 62 | 63 | An event captures a fact with regards to the application state, 64 | e.g. that a user logged in or signed up. Events carry all the 65 | necessary data for reconstructing state and building read models. For 66 | example, a "user signed up" event most likely contains the user's 67 | name, a cryptographically hashed version of the password she provided 68 | and her contact email address. 69 | 70 | Events are persisted in an append-only log, the event history. If 71 | persisting the events succeeds, any interested parties are notified 72 | about this. 73 | 74 | Projections process events as they happen and use them to build some 75 | sort of state. This state can be stored anywhere and can take any 76 | form, because the application does not depend on this projected state. 77 | Projected state can be thrown away and rebuilt from history if 78 | necessary, because all important information has been captured in the 79 | event history already. 80 | 81 | Tutorial 82 | 83 | Building an application using event sourcing works best by working 84 | from the outside in. Start with the user story and think about which 85 | information the user will have to enter to fulfill his goal. This 86 | example looks at building user sign up for your application. We 87 | imagine the form to look somewhat like this: 88 | 89 | 90 | Sign up for $COOL_PRODUCT 91 | 92 | Username: [__________________] 93 | Your Name: [__________________] 94 | Your Email: [__________________] 95 | Password: [__________________] 96 | 97 | [ Sign up ] 98 | 99 | This translates directly to a command, "sign-up", capturing the data 100 | of the form: 101 | 102 | var SignUp = ess.NewCommandDefinition("sign-up"). 103 | Id("username", ess.Id()). 104 | Field("name", ess.TrimmedString()). 105 | Field("email", ess.EmailAddress()). 106 | Field("password", ess.Password()). 107 | Target(NewUserFromCommand) 108 | 109 | The above definition mirrors the data captured in the form. This 110 | package already provides types for parsing email addresses and 111 | handling password input parameters, so we use those to capture the 112 | user's mail address and password. 113 | 114 | In order to ensure uniqueness, we let the user choose a username for 115 | our platform. The chosen username will serve as the user's id. We 116 | could use the user's email addresses instead but then we'd get into 117 | trouble once the user changes her contact email address. 118 | 119 | The function NewUserFromCommand is responsible for creating a new user 120 | object from a command matching above structure: 121 | 122 | func NewUserFromCommand(command *ess.Command) Aggregate { 123 | return NewUser(command.Get("username").String()) 124 | } 125 | 126 | The user object is responsible for ensuring our business rules about 127 | users: 128 | 129 | - the username needs to be unique 130 | 131 | - an email address needs to be provided 132 | 133 | - a password needs to be present 134 | 135 | This is an empty implementation of our user object, providing all the 136 | necessary methods for acting as an Aggregate. 137 | 138 | type User struct { 139 | id string 140 | events ess.EventPublisher 141 | } 142 | 143 | func NewUser(username string) *User { 144 | return &User{ 145 | id: username, 146 | } 147 | } 148 | 149 | func (self *User) PublishWith(publisher ess.EventPublisher) ess.Aggregate { 150 | self.events = publisher 151 | return self 152 | } 153 | 154 | func (self *User) Id() string { 155 | return self.id 156 | } 157 | 158 | func (self *User) HandleCommand(command *ess.Command) error { 159 | // ... 160 | return nil 161 | } 162 | 163 | func (self *User) HandleEvent(event *ess.Event) { 164 | // ... 165 | } 166 | 167 | 168 | When a user submits the form, a new instance of this object will be 169 | created by the NewUserFromCommand function and the object's 170 | HandleCommand method will be called. So let's add the signup logic 171 | there: 172 | 173 | func (self *User) HandleCommand(command *ess.Command) error { 174 | switch command.Name { 175 | case "sign-up": 176 | return self.SignUp( 177 | command.Get("name").String(), 178 | command.Get("email").String(), 179 | command.Get("password").String(), 180 | ) 181 | } 182 | return nil 183 | } 184 | 185 | func (self *User) SignUp(name, email, password string) error { 186 | err := ess.NewValidationError() 187 | 188 | // How to check for username uniqueness? 189 | 190 | if password == "" { 191 | err.Add("password", "empty") 192 | } 193 | 194 | if email == "" { 195 | err.Add("email", "empty") 196 | } 197 | 198 | if err.Ok() { 199 | self.events.PublishEvent( 200 | ess.NewEvent("user.signed-up"). 201 | For(self). 202 | Add("password", password). 203 | Add("email", email). 204 | Add("name", name), 205 | ) 206 | } 207 | 208 | return err.Return() 209 | } 210 | 211 | Checking for the presence of the password and email fields is pretty 212 | straightforward, but how can we ensure that every username is unique? 213 | We can arrive at an answer by looking at what "unique username" means: 214 | it means that no user must have signed up with the same username 215 | already. The phrase "signed up" is key here -- it points us to an 216 | event. In other words: trying to sign up a user who has already 217 | signed up should fail. 218 | 219 | Let's add a field to our user object and note the fact that the user 220 | has signed up in the user's HandleEvent method: 221 | 222 | type User struct { 223 | // existing fields omitted 224 | signedUp bool 225 | } 226 | 227 | func (self *User) HandleEvent(event *ess.Event) { 228 | switch event.Name { 229 | case "user.signed-up": 230 | self.signedUp = true 231 | } 232 | } 233 | 234 | Now we can check this field in the SignUp method and return an error: 235 | 236 | func (self *User) SignUp(name, email, password string) error { 237 | err := ess.NewValidationError() 238 | 239 | if self.signedUp { 240 | err.Add("username", "not_unique") 241 | } 242 | 243 | // ... 244 | } 245 | 246 | That's it! Adding new commands follows the same process. 247 | 248 | Let's hook everything up to see the whole example in action: 249 | 250 | import ( 251 | "fmt" 252 | "log" 253 | "net/http" 254 | 255 | "github.com/dhamidi/ess" 256 | ) 257 | 258 | func main() { 259 | app := ess.NewApplication("user-signup-example") 260 | 261 | http.HandleFunc("/", ShowSignupForm) 262 | http.Handle("/signups", HandleSignup(app)) 263 | log.Fatal(http.ListenAndServe("localhost:8080", nil)) 264 | } 265 | 266 | func ShowSignupForm(w http.ResponseWriter, req *http.Request) { 267 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 268 | fmt.Fprintf(w, `Signup 269 | 270 |

Sign up

271 |
272 |

273 | 274 | 275 |

276 |

277 | 278 | 279 |

280 |

281 | 282 | 283 |

284 |

285 | 286 | 287 |

288 |

289 | 290 |

291 |
292 | `) 293 | } 294 | 295 | func HandleSignup(app *ess.Application) http.Handler { 296 | handler := func(w http.ResponseWriter, req *http.Request) { 297 | command := SignUp.FromForm(req) 298 | result := app.Send(command) 299 | 300 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 301 | if err := result.Error(); err != nil { 302 | fmt.Fprintf(w, "Errors: %s\n", err) 303 | } else { 304 | fmt.Fprintf(w, "Signed up successfully.\n") 305 | } 306 | } 307 | return http.HandlerFunc(handler) 308 | } 309 | */ 310 | package ess 311 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import "time" 4 | 5 | // Event represents a state change that has occurred. Events are 6 | // named in the past tense, e.g. "user.signed-up". 7 | type Event struct { 8 | // Id is the unique identifier of this event. 9 | Id string 10 | 11 | // StreamId is the id of the aggregate that emitted this 12 | // event. 13 | StreamId string 14 | 15 | // Name names the type of the event. 16 | Name string 17 | 18 | // OccurredOn is the time at which the application has seen 19 | // the event. 20 | OccurredOn time.Time 21 | 22 | // PersistedAt is the time at which the event has been written 23 | // to persistent storage. 24 | PersistedAt time.Time 25 | 26 | // Payload is additional data that needed to be recorded with 27 | // the event in order to reconstruct state. 28 | Payload map[string]interface{} 29 | } 30 | 31 | // NewEvent creates a new, empty event of type name. 32 | func NewEvent(name string) *Event { 33 | return &Event{ 34 | Name: name, 35 | Payload: map[string]interface{}{}, 36 | } 37 | } 38 | 39 | // For marks the event as being emitted by source. 40 | func (self *Event) For(source Aggregate) *Event { 41 | self.StreamId = source.Id() 42 | return self 43 | } 44 | 45 | // Add sets the payload for the field name to value. 46 | func (self *Event) Add(name string, value interface{}) *Event { 47 | self.Payload[name] = value 48 | return self 49 | } 50 | 51 | // Occur marks the occurrence time of the event according to clock. 52 | func (self *Event) Occur(clock Clock) *Event { 53 | self.OccurredOn = clock.Now() 54 | return self 55 | } 56 | 57 | // Persist marks the time of persisting the event according to clock. 58 | func (self *Event) Persist(clock Clock) *Event { 59 | self.PersistedAt = clock.Now() 60 | return self 61 | } 62 | -------------------------------------------------------------------------------- /event_store_test.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestEventsInMemory_EventStoreBehavior(t *testing.T) { 11 | setup := func(t *testing.T) EventStore { return NewEventsInMemory() } 12 | suite := NewEventStoreTest(setup) 13 | suite.Run(t) 14 | } 15 | 16 | func TestEventsOnDisk_EventStoreBehavior(t *testing.T) { 17 | filename := filepath.Join(os.TempDir(), fmt.Sprintf("events-%d.json", os.Getpid())) 18 | teardown := func() { 19 | os.Remove(filename) 20 | } 21 | setup := func(t *testing.T) EventStore { 22 | store, err := NewEventsOnDisk(filename, SystemClock) 23 | if err != nil { 24 | t.Fatalf("EventsOnDisk setup [filename=%q]: %s", filename, err) 25 | } 26 | return store 27 | } 28 | 29 | suite := NewEventStoreTest(setup) 30 | suite.TearDown = teardown 31 | 32 | suite.Run(t) 33 | } 34 | -------------------------------------------------------------------------------- /event_store_test_suite.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import "testing" 4 | 5 | // EventStoreTest encapsulates the tests for the EventStore interface. 6 | // Any compliant implementation of an EventStore should pass these 7 | // tests. 8 | // 9 | // This type is public so that implementations of an EventStore 10 | // outside of this package can be tested. 11 | type EventStoreTest struct { 12 | // SetUp is responsible for creating a new EventStore 13 | // instance. It is called before each test. 14 | SetUp func(t *testing.T) EventStore 15 | 16 | // TearDown is responsible for doing any cleanup work. It is 17 | // called at the end of each test. 18 | TearDown func() 19 | } 20 | 21 | // NewEventStoreTest returns a new test suite using setup as the test 22 | // setup function. TearDown is set to do nothing. 23 | func NewEventStoreTest(setup func(t *testing.T) EventStore) *EventStoreTest { 24 | return &EventStoreTest{ 25 | SetUp: setup, 26 | TearDown: func() {}, 27 | } 28 | } 29 | 30 | // Run runs all tests. 31 | func (self *EventStoreTest) Run(t *testing.T) { 32 | self.testStoredEventsCanBeReplayedByStreamId(t) 33 | self.testStoredEventsCanBeReplayedOverAllStreams(t) 34 | } 35 | 36 | func (self *EventStoreTest) testStoredEventsCanBeReplayedByStreamId(t *testing.T) { 37 | store := self.SetUp(t) 38 | t.Logf("testStoredEventsCanBeReplayedByStreamId %T", store) 39 | defer self.TearDown() 40 | 41 | subject := newTestAggregate("id") 42 | other := newTestAggregate("other") 43 | 44 | history := []*Event{ 45 | NewEvent("test.run-1").For(subject).Add("param", "value"), 46 | NewEvent("test.run-1").For(other).Add("param", "other"), 47 | NewEvent("test.run-2").For(subject).Add("param", "new-value"), 48 | } 49 | 50 | if err := store.Store(history); err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | seen := []string{} 55 | if err := store.Replay(subject.Id(), EventHandlerFunc(func(event *Event) { 56 | seen = append(seen, event.Name) 57 | })); err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | if got, want := len(seen), 2; got != want { 62 | t.Fatalf(`len(seen) = %v; want %v`, got, want) 63 | } 64 | 65 | if got, want := seen[0], history[0].Name; got != want { 66 | t.Errorf(`seen[0] = %v; want %v`, got, want) 67 | } 68 | 69 | if got, want := seen[1], history[2].Name; got != want { 70 | t.Errorf(`seen[1] = %v; want %v`, got, want) 71 | } 72 | 73 | } 74 | 75 | func (self *EventStoreTest) testStoredEventsCanBeReplayedOverAllStreams(t *testing.T) { 76 | store := self.SetUp(t) 77 | t.Logf("testStoredEventsCanBeReplayedOverAllStreams %T", store) 78 | defer self.TearDown() 79 | 80 | subject := newTestAggregate("id") 81 | other := newTestAggregate("other") 82 | 83 | history := []*Event{ 84 | NewEvent("test.run-1").For(subject).Add("param", "value"), 85 | NewEvent("test.run-1").For(other).Add("param", "other"), 86 | NewEvent("test.run-2").For(subject).Add("param", "new-value"), 87 | } 88 | 89 | if err := store.Store(history); err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | seen := []string{} 94 | if err := store.Replay("*", EventHandlerFunc(func(event *Event) { 95 | seen = append(seen, event.Name) 96 | })); err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | if got, want := len(seen), 3; got != want { 101 | t.Fatalf(`len(seen) = %v; want %v`, got, want) 102 | } 103 | 104 | if got, want := seen[0], history[0].Name; got != want { 105 | t.Errorf(`seen[0] = %v; want %v`, got, want) 106 | } 107 | 108 | if got, want := seen[1], history[1].Name; got != want { 109 | t.Errorf(`seen[1] = %v; want %v`, got, want) 110 | } 111 | 112 | if got, want := seen[2], history[2].Name; got != want { 113 | t.Errorf(`seen[2] = %v; want %v`, got, want) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestEvent_For_usesAggregateIdAsStreamId(t *testing.T) { 9 | aggregate := newTestAggregate("id") 10 | event := NewEvent("test.run").For(aggregate) 11 | 12 | if got, want := event.StreamId, aggregate.Id(); got != want { 13 | t.Errorf(`event.StreamId = %v; want %v`, got, want) 14 | } 15 | } 16 | 17 | func TestEvent_Add_addsFieldToPayload(t *testing.T) { 18 | event := NewEvent("test.run"). 19 | Add("a", 1). 20 | Add("b", 2) 21 | 22 | if got, want := event.Payload["a"].(int), 1; got != want { 23 | t.Errorf(`event.Payload["a"].(int) = %v; want %v`, got, want) 24 | } 25 | 26 | if got, want := event.Payload["b"].(int), 2; got != want { 27 | t.Errorf(`event.Payload["b"].(int) = %v; want %v`, got, want) 28 | } 29 | 30 | } 31 | 32 | func TestEvent_Add_overwritesExistingValues(t *testing.T) { 33 | event := NewEvent("test.run"). 34 | Add("a", 1). 35 | Add("a", 2) 36 | 37 | if got, want := event.Payload["a"], 2; got != want { 38 | t.Errorf(`event.Payload["a"] = %v; want %v`, got, want) 39 | } 40 | 41 | } 42 | 43 | func TestEvent_Occur_setsOccurredOnBasedOnClock(t *testing.T) { 44 | clock := &StaticClock{time.Now()} 45 | event := NewEvent("test.run"). 46 | Occur(clock) 47 | 48 | if got, want := event.OccurredOn, clock.Time; !got.Equal(want) { 49 | t.Errorf(`event.OccurredOn = %v; want %v`, got, want) 50 | } 51 | } 52 | 53 | func TestEvent_Persist_setsPersistedAtBasedOnClock(t *testing.T) { 54 | clock := &StaticClock{time.Now()} 55 | event := NewEvent("test.run"). 56 | Persist(clock) 57 | 58 | if got, want := event.PersistedAt, clock.Time; !got.Equal(want) { 59 | t.Errorf(`event.PersistedAt = %v; want %v`, got, want) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /events_in_memory.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | // EventsInMemory is an in-memory implementation of an event store. 4 | type EventsInMemory struct { 5 | events []*Event 6 | } 7 | 8 | // NewEventsInMemory creates a new instance of this event store 9 | // holding no events initially. 10 | func NewEventsInMemory() *EventsInMemory { 11 | return &EventsInMemory{ 12 | events: []*Event{}, 13 | } 14 | } 15 | 16 | // Store stores the given events in this event store. It never 17 | // returns an error. 18 | func (self *EventsInMemory) Store(events []*Event) error { 19 | self.events = append(self.events, events...) 20 | return nil 21 | } 22 | 23 | // Replay handles all events with a matching stream id using receiver. 24 | // It never returns an error. 25 | // 26 | // Use "*" as the stream id to match all events. 27 | func (self *EventsInMemory) Replay(streamId string, receiver EventHandler) error { 28 | for _, event := range self.events { 29 | if streamId == "*" || streamId == event.StreamId { 30 | receiver.HandleEvent(event) 31 | } 32 | } 33 | return nil 34 | } 35 | 36 | // PublishEvent stores event in this instance. This method is 37 | // implemented to satisfy the EventPublisher interface. 38 | // 39 | // Using an EventsInMemory instance as an event publisher allows for 40 | // capturing events across aggregates and facilitates testing. 41 | func (self *EventsInMemory) PublishEvent(event *Event) EventPublisher { 42 | self.events = append(self.events, event) 43 | return self 44 | } 45 | 46 | // Events returns all events stored by this instance. 47 | func (self *EventsInMemory) Events() []*Event { 48 | return self.events 49 | } 50 | -------------------------------------------------------------------------------- /events_on_disk.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // EventsOnDisk is a persistent, file-based implementation of an 11 | // EventStore. 12 | // 13 | // Events are serialized as JSON and appended to a log file. Storing 14 | // and replaying events access the disk. File handles are kept open 15 | // no longer than necessary. 16 | type EventsOnDisk struct { 17 | filename string 18 | clock Clock 19 | } 20 | 21 | // NewEventsOnDisk returns an new instance appending events to file 22 | // and using clock for marking events as persisted. 23 | func NewEventsOnDisk(file string, clock Clock) (*EventsOnDisk, error) { 24 | return &EventsOnDisk{ 25 | filename: filepath.Clean(file), 26 | clock: clock, 27 | }, nil 28 | } 29 | 30 | // Store stores events by serializing them as JSON and appending them 31 | // to the configured log file. Intermediate directories are created. 32 | func (self *EventsOnDisk) Store(events []*Event) error { 33 | os.MkdirAll(filepath.Dir(self.filename), 0700) 34 | out, err := os.OpenFile(self.filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) 35 | if err != nil { 36 | return err 37 | } 38 | defer out.Close() 39 | 40 | enc := json.NewEncoder(out) 41 | for _, event := range events { 42 | event.Persist(self.clock) 43 | if err := enc.Encode(event); err != nil { 44 | return err 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // Replay replays all events matching streamId using receiver. 52 | // 53 | // Events are deserialized from the log file and then passed to 54 | // receiver. 55 | // 56 | // Use "*" as the streamId to match all events. 57 | func (self *EventsOnDisk) Replay(streamId string, receiver EventHandler) error { 58 | in, err := os.Open(self.filename) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | dec := json.NewDecoder(in) 64 | for { 65 | event := Event{} 66 | err := dec.Decode(&event) 67 | if err == io.EOF { 68 | break 69 | } else if err != nil { 70 | return err 71 | } 72 | 73 | if streamId == "*" || streamId == event.StreamId { 74 | receiver.HandleEvent(&event) 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /examples/blog/all_posts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/dhamidi/ess" 10 | ) 11 | 12 | var ( 13 | ErrNotFound = errors.New("not_found") 14 | ) 15 | 16 | type ProjectedPost struct { 17 | Id string `json:"id"` 18 | Title string `json:"title"` 19 | Body string `json:"body"` 20 | 21 | Paragraphs []string `json:"paragraphs"` 22 | 23 | Path string `json:"path"` 24 | WrittenAt time.Time `json:"writtenAt"` 25 | 26 | Author string `json:"author"` 27 | } 28 | 29 | func NewProjectedPostFromEvent(event *ess.Event) *ProjectedPost { 30 | post := &ProjectedPost{Id: event.StreamId} 31 | post.Update(event) 32 | return post 33 | } 34 | 35 | func (self *ProjectedPost) Update(event *ess.Event) *ProjectedPost { 36 | self.Title = event.Payload["title"].(string) 37 | self.Body = event.Payload["body"].(string) 38 | self.Paragraphs = strings.Split( 39 | strings.NewReplacer("\r\n", "\n").Replace(self.Body), 40 | "\n", 41 | ) 42 | self.Path = fmt.Sprintf("/posts/%s", self.Id) 43 | 44 | if author := event.Payload["author"]; author != nil { 45 | self.Author = author.(string) 46 | } else { 47 | self.Author = "anonymous" 48 | } 49 | 50 | if event.Name == "post.written" { 51 | self.WrittenAt = event.OccurredOn 52 | } 53 | 54 | return self 55 | } 56 | 57 | type AllPostsInMemory struct { 58 | byId map[string]*ProjectedPost 59 | recent []*ProjectedPost 60 | } 61 | 62 | func NewAllPostsInMemory() *AllPostsInMemory { 63 | return &AllPostsInMemory{ 64 | byId: map[string]*ProjectedPost{}, 65 | recent: []*ProjectedPost{}, 66 | } 67 | } 68 | 69 | func (self *AllPostsInMemory) HandleEvent(event *ess.Event) { 70 | switch event.Name { 71 | case "post.edited": 72 | self.byId[event.StreamId].Update(event) 73 | case "post.written": 74 | post := NewProjectedPostFromEvent(event) 75 | self.byId[event.StreamId] = post 76 | self.recent = append([]*ProjectedPost{post}, self.recent...) 77 | } 78 | } 79 | 80 | func (self *AllPostsInMemory) ById(id string) (*ProjectedPost, error) { 81 | post, found := self.byId[id] 82 | if !found { 83 | return nil, ErrNotFound 84 | } 85 | return post, nil 86 | } 87 | 88 | func (self *AllPostsInMemory) Recent() ([]*ProjectedPost, error) { 89 | return self.recent, nil 90 | } 91 | -------------------------------------------------------------------------------- /examples/blog/html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "io" 8 | ) 9 | 10 | type HTML struct { 11 | Name string 12 | Attributes HTMLAttributes 13 | Children []*HTML 14 | Content string 15 | } 16 | 17 | var ( 18 | escape = template.HTMLEscapeString 19 | H = &HTML{} 20 | ) 21 | 22 | func (self *HTML) String() string { 23 | buf := new(bytes.Buffer) 24 | self.WriteHTML(buf, "", " ") 25 | return buf.String() 26 | } 27 | 28 | func (self *HTML) WriteHTML(w io.Writer, prefix, indent string) { 29 | if self.Name == "TEXT" { 30 | fmt.Fprintf(w, "%s", escape(self.Content)) 31 | return 32 | } 33 | 34 | if self.Name == "CDATA" { 35 | fmt.Fprintf(w, "%s%s", prefix, self.Content) 36 | return 37 | } 38 | 39 | out := w 40 | if out == nil { 41 | out = new(bytes.Buffer) 42 | } 43 | 44 | fmt.Fprintf(out, "%s<%s", prefix, self.Name) 45 | if len(self.Attributes) > 0 { 46 | fmt.Fprintf(out, " ") 47 | count := 0 48 | for attr, value := range self.Attributes { 49 | fmt.Fprintf(out, `%s="%s"`, escape(attr), escape(value)) 50 | count++ 51 | if count < len(self.Attributes) { 52 | fmt.Fprintf(out, " ") 53 | } 54 | } 55 | } 56 | fmt.Fprintf(out, ">\n") 57 | for _, child := range self.Children { 58 | child.WriteHTML(out, prefix+indent, indent) 59 | fmt.Fprintf(out, "\n") 60 | } 61 | fmt.Fprintf(out, "%s", prefix, self.Name) 62 | } 63 | 64 | func (self *HTML) T(name string, attributes HTMLAttributes, children ...*HTML) *HTML { 65 | return &HTML{ 66 | Name: name, 67 | Attributes: attributes, 68 | Children: children, 69 | } 70 | } 71 | 72 | func (self *HTML) C(children ...*HTML) *HTML { 73 | self.Children = append(self.Children, children...) 74 | return self 75 | } 76 | 77 | func (self *HTML) Text(text string) *HTML { 78 | return &HTML{Content: text, Name: "TEXT"} 79 | } 80 | 81 | func (self *HTML) Lit(text string) *HTML { 82 | return &HTML{Content: text, Name: "CDATA"} 83 | } 84 | 85 | func (self *HTML) A(name, value string) HTMLAttributes { 86 | return HTMLAttributes{name: value} 87 | } 88 | 89 | type HTMLAttributes map[string]string 90 | 91 | func (self HTMLAttributes) A(name, value string) HTMLAttributes { 92 | self[name] = value 93 | return self 94 | } 95 | 96 | func NewHTMLDocument(title string, body ...*HTML) *HTML { 97 | h := &HTML{} 98 | return h.T("html", nil, 99 | h.T("head", nil, 100 | h.T("meta", h.A("charset", "utf")), 101 | h.T("title", nil, h.Text(title)), 102 | h.T("style", nil, h.Lit(stylesheet)), 103 | ), 104 | h.T("body", nil, body...), 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /examples/blog/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/dhamidi/ess" 14 | ) 15 | 16 | var ( 17 | WritePost = ess.NewCommandDefinition("write-post"). 18 | Field("id", ess.Id()). 19 | Field("title", ess.TrimmedString()). 20 | Field("body", ess.TrimmedString()). 21 | Field("username", ess.Id()). 22 | Target(PostFromCommand) 23 | 24 | EditPost = ess.NewCommandDefinition("edit-post"). 25 | Field("id", ess.Id()). 26 | Field("title", ess.TrimmedString()). 27 | Field("body", ess.TrimmedString()). 28 | Field("reason", ess.TrimmedString()). 29 | Field("username", ess.Id()). 30 | Target(PostFromCommand) 31 | 32 | SignUp = ess.NewCommandDefinition("sign-up"). 33 | Id("username", ess.Id()). 34 | Field("email", ess.EmailAddress()). 35 | Field("password", ess.Password()). 36 | Target(UserFromCommand) 37 | 38 | LogIn = ess.NewCommandDefinition("login"). 39 | Id("username", ess.Id()). 40 | Field("password", ess.Password()). 41 | Field("session", ess.TrimmedString()). 42 | Target(UserFromCommand) 43 | 44 | LogOut = ess.NewCommandDefinition("logout"). 45 | Id("username", ess.Id()). 46 | Field("session", ess.TrimmedString()). 47 | Target(UserFromCommand) 48 | ) 49 | 50 | func PostFromCommand(params *ess.Command) ess.Aggregate { 51 | return NewPost(params.AggregateId()) 52 | } 53 | 54 | func UserFromCommand(params *ess.Command) ess.Aggregate { 55 | return NewUser(params.Get("username").String()) 56 | } 57 | 58 | type SignupsResource struct { 59 | app *ess.Application 60 | } 61 | 62 | func (self *SignupsResource) ServeHTTP(w http.ResponseWriter, req *http.Request) { 63 | result := (*ess.CommandResult)(nil) 64 | switch req.Method { 65 | case "GET": 66 | ShowSignupForm(w) 67 | case "POST": 68 | params := SignUp.FromForm(req) 69 | result = self.app.Send(params) 70 | if err := result.Error(); err != nil { 71 | ShowSignupFormErrors(w, params, err) 72 | } else { 73 | http.Redirect(w, req, "/sessions", http.StatusSeeOther) 74 | } 75 | default: 76 | MethodNotSupported(w) 77 | } 78 | } 79 | 80 | type SessionsResource struct { 81 | app *ess.Application 82 | allSessions SessionStore 83 | } 84 | 85 | func (self *SessionsResource) ServeHTTP(w http.ResponseWriter, req *http.Request) { 86 | action := strings.TrimPrefix(req.URL.Path, "/sessions/") 87 | 88 | switch req.Method { 89 | case "GET": 90 | ShowLoginForm(w, req) 91 | case "POST": 92 | req.ParseForm() 93 | if action == "logout" { 94 | self.Logout(w, req) 95 | } else { 96 | self.Login(w, req) 97 | } 98 | 99 | default: 100 | MethodNotSupported(w) 101 | } 102 | 103 | } 104 | 105 | func (self *SessionsResource) Logout(w http.ResponseWriter, req *http.Request) { 106 | currentUser := loadCurrentUser(req, self.allSessions) 107 | if currentUser != nil { 108 | req.Form["session"] = []string{currentUser.SessionId} 109 | req.Form["username"] = []string{currentUser.Username} 110 | params := LogOut.FromForm(req) 111 | self.app.Send(params) 112 | } 113 | http.Redirect(w, req, "/", http.StatusSeeOther) 114 | } 115 | 116 | func (self *SessionsResource) Login(w http.ResponseWriter, req *http.Request) { 117 | sessionId := GenerateSessionId() 118 | req.Form["session"] = []string{sessionId} 119 | params := LogIn.FromForm(req) 120 | result := self.app.Send(params) 121 | if err := result.Error(); err != nil { 122 | ShowLoginFormError(w, params, err) 123 | } else { 124 | http.SetCookie(w, &http.Cookie{ 125 | Name: "session", 126 | Value: sessionId, 127 | Expires: time.Now().Add(24 * time.Hour), 128 | Path: "/", 129 | Domain: req.URL.Host, 130 | HttpOnly: true, 131 | }) 132 | returnTo := "/" 133 | if returnPath := req.FormValue("return"); returnPath != "" { 134 | returnTo = returnPath 135 | } 136 | http.Redirect(w, req, returnTo, http.StatusSeeOther) 137 | } 138 | } 139 | 140 | type PostsResource struct { 141 | app *ess.Application 142 | allSessions *AllSessionsInMemory 143 | } 144 | 145 | func (self *PostsResource) ServeHTTP(w http.ResponseWriter, req *http.Request) { 146 | currentUser := loadCurrentUser(req, self.allSessions) 147 | if currentUser == nil { 148 | RequireLogin(w, req) 149 | return 150 | } 151 | 152 | switch req.Method { 153 | case "GET": 154 | ShowPostForm(w) 155 | case "POST": 156 | req.ParseForm() 157 | req.Form["username"] = []string{currentUser.Username} 158 | params := WritePost.FromForm(req) 159 | result := self.app.Send(params) 160 | if err := result.Error(); err != nil { 161 | ShowPostFormError(w, params, err) 162 | } else { 163 | http.Redirect(w, req, "/posts/"+params.Get("id").String(), http.StatusSeeOther) 164 | } 165 | } 166 | } 167 | 168 | func MethodNotSupported(w http.ResponseWriter) { 169 | http.Error(w, "Method Not Supported", http.StatusMethodNotAllowed) 170 | } 171 | 172 | func NotFound(w http.ResponseWriter) { 173 | http.Error(w, "404 Not Found", http.StatusNotFound) 174 | } 175 | 176 | func RequireLogin(w http.ResponseWriter, req *http.Request) { 177 | 178 | returnTo := &url.URL{ 179 | Path: "/sessions", 180 | RawQuery: url.Values{ 181 | "return": []string{req.URL.Path}, 182 | }.Encode(), 183 | } 184 | http.Redirect(w, req, returnTo.String(), http.StatusSeeOther) 185 | } 186 | 187 | func ShowResult(w http.ResponseWriter, result *ess.CommandResult) { 188 | if result == nil { 189 | http.Error(w, "Not Found", http.StatusNotFound) 190 | return 191 | } 192 | if err := result.Error(); err != nil { 193 | w.Header().Set("Content-Type", "application/json") 194 | w.WriteHeader(http.StatusBadRequest) 195 | json.NewEncoder(w).Encode(err) 196 | } else { 197 | fmt.Fprintf(w, "{\"status\":\"ok\"}\n") 198 | } 199 | } 200 | 201 | func Show(w http.ResponseWriter, thing interface{}) { 202 | w.Header().Set("Content-Type", "application/json") 203 | json.NewEncoder(w).Encode(thing) 204 | } 205 | 206 | type PostResource struct { 207 | app *ess.Application 208 | allPosts *AllPostsInMemory 209 | allSessions SessionStore 210 | } 211 | 212 | func (self *PostResource) ServeHTTP(w http.ResponseWriter, req *http.Request) { 213 | subpath := strings.TrimPrefix(req.URL.Path, "/posts/") 214 | fields := strings.Split(subpath, "/") 215 | postId := fields[0] 216 | action := "" 217 | if len(fields) > 1 { 218 | action = fields[1] 219 | } 220 | 221 | switch action { 222 | case "": 223 | post, err := self.allPosts.ById(postId) 224 | 225 | if err != nil { 226 | NotFound(w) 227 | } else { 228 | ShowPost(w, post) 229 | } 230 | case "edit": 231 | self.handleEdits(w, req, postId) 232 | } 233 | } 234 | 235 | func (self *PostResource) handleEdits(w http.ResponseWriter, req *http.Request, postId string) { 236 | currentUser := loadCurrentUser(req, self.allSessions) 237 | if currentUser == nil { 238 | RequireLogin(w, req) 239 | return 240 | } 241 | 242 | post, err := self.allPosts.ById(postId) 243 | if err != nil { 244 | NotFound(w) 245 | return 246 | } 247 | 248 | switch req.Method { 249 | case "GET": 250 | params := EditPost.NewCommand(). 251 | Set("username", currentUser.Username). 252 | Set("title", post.Title). 253 | Set("body", post.Body). 254 | Set("id", postId) 255 | 256 | ShowEditPostForm(w, params) 257 | case "POST": 258 | params := EditPost.FromForm(req).Set("id", postId) 259 | result := self.app.Send(params) 260 | if err := result.Error(); err != nil { 261 | ShowEditPostFormError(w, params, err) 262 | } else { 263 | http.Redirect(w, req, post.Path, http.StatusSeeOther) 264 | } 265 | default: 266 | MethodNotSupported(w) 267 | } 268 | } 269 | 270 | type IndexResource struct { 271 | app *ess.Application 272 | allPosts *AllPostsInMemory 273 | allSessions *AllSessionsInMemory 274 | } 275 | 276 | func (self *IndexResource) ServeHTTP(w http.ResponseWriter, req *http.Request) { 277 | switch req.Method { 278 | case "GET": 279 | currentUser := loadCurrentUser(req, self.allSessions) 280 | allPosts, _ := self.allPosts.Recent() 281 | ShowAllPostsIndex(w, currentUser, allPosts) 282 | default: 283 | MethodNotSupported(w) 284 | } 285 | } 286 | 287 | type SessionStore interface { 288 | ById(id string) (*ProjectedUser, error) 289 | } 290 | 291 | func loadCurrentUser(req *http.Request, sessions SessionStore) *ProjectedUser { 292 | sessionCookie, err := req.Cookie("session") 293 | if err != nil { 294 | return nil 295 | } 296 | 297 | user, err := sessions.ById(sessionCookie.Value) 298 | if err != nil { 299 | return nil 300 | } 301 | return user 302 | } 303 | 304 | func main() { 305 | logger := log.New(os.Stderr, "blog ", 0) 306 | store, err := ess.NewEventsOnDisk("events.json", ess.SystemClock) 307 | if err != nil { 308 | logger.Fatal(err) 309 | } 310 | 311 | allPostsInMemory := NewAllPostsInMemory() 312 | allSessionsInMemory := NewAllSessionsInMemory() 313 | application := ess.NewApplication("blog"). 314 | WithLogger(logger). 315 | WithStore(store). 316 | WithProjection("all-posts", allPostsInMemory). 317 | WithProjection("all-sessions", allSessionsInMemory) 318 | 319 | if err := application.Init(); err != nil { 320 | logger.Fatal(err) 321 | } 322 | 323 | http.Handle("/sessions", &SessionsResource{app: application, allSessions: allSessionsInMemory}) 324 | http.Handle("/sessions/", &SessionsResource{app: application, allSessions: allSessionsInMemory}) 325 | http.Handle("/signups", &SignupsResource{app: application}) 326 | http.Handle("/posts/", &PostResource{app: application, allPosts: allPostsInMemory, allSessions: allSessionsInMemory}) 327 | http.Handle("/posts", &PostsResource{app: application, allSessions: allSessionsInMemory}) 328 | http.Handle("/", &IndexResource{app: application, allPosts: allPostsInMemory, allSessions: allSessionsInMemory}) 329 | 330 | logger.Fatal(http.ListenAndServe(args(args(os.Args[1:]...), "localhost:6060"), nil)) 331 | } 332 | 333 | func args(argv ...string) string { 334 | for _, arg := range argv { 335 | if arg != "" { 336 | return arg 337 | } 338 | } 339 | 340 | return "" 341 | } 342 | -------------------------------------------------------------------------------- /examples/blog/post.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/dhamidi/ess" 4 | 5 | type Post struct { 6 | events ess.EventPublisher 7 | id string 8 | written bool 9 | author string 10 | } 11 | 12 | func NewPost(id string) *Post { 13 | return &Post{id: id} 14 | } 15 | 16 | func (self *Post) Id() string { return self.id } 17 | func (self *Post) PublishWith(publisher ess.EventPublisher) ess.Aggregate { 18 | self.events = publisher 19 | return self 20 | } 21 | 22 | func (self *Post) HandleEvent(event *ess.Event) { 23 | switch event.Name { 24 | case "post.written": 25 | self.written = true 26 | if author := event.Payload["author"]; author != nil { 27 | self.author = author.(string) 28 | } 29 | } 30 | } 31 | 32 | func (self *Post) HandleCommand(command *ess.Command) error { 33 | switch command.Name { 34 | case "write-post": 35 | return self.Write( 36 | command.Get("title").String(), 37 | command.Get("body").String(), 38 | command.Get("username").String(), 39 | ) 40 | case "edit-post": 41 | return self.Edit( 42 | command.Get("title").String(), 43 | command.Get("body").String(), 44 | command.Get("reason").String(), 45 | command.Get("username").String(), 46 | ) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (self *Post) Edit(title, body, reason, username string) error { 53 | err := ess.NewValidationError() 54 | 55 | if !self.written { 56 | err.Add("post", "not_found") 57 | } 58 | 59 | if title == "" { 60 | err.Add("title", "empty") 61 | } 62 | 63 | if body == "" { 64 | err.Add("body", "empty") 65 | } 66 | 67 | if username != self.author { 68 | err.Add("username", "mismatch") 69 | } 70 | 71 | if reason == "" { 72 | err.Add("reason", "empty") 73 | } 74 | 75 | if err.Ok() { 76 | self.events.PublishEvent( 77 | ess.NewEvent("post.edited"). 78 | For(self). 79 | Add("title", title). 80 | Add("body", body). 81 | Add("author", username). 82 | Add("reason", reason), 83 | ) 84 | } 85 | 86 | return err.Return() 87 | } 88 | 89 | func (self *Post) Write(title, body, author string) error { 90 | err := ess.NewValidationError() 91 | 92 | if self.written { 93 | err.Add("post", "not_unique") 94 | } 95 | 96 | if title == "" { 97 | err.Add("title", "empty") 98 | } 99 | 100 | if body == "" { 101 | err.Add("body", "empty") 102 | } 103 | 104 | if author == "" { 105 | err.Add("username", "empty") 106 | } 107 | 108 | if err.Ok() { 109 | self.events.PublishEvent( 110 | ess.NewEvent("post.written"). 111 | For(self). 112 | Add("title", title). 113 | Add("author", author). 114 | Add("body", body), 115 | ) 116 | } 117 | 118 | return err.Return() 119 | } 120 | -------------------------------------------------------------------------------- /examples/blog/sessions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | 7 | "github.com/dhamidi/ess" 8 | ) 9 | 10 | func GenerateSessionId() string { 11 | id := make([]byte, 16) 12 | _, err := rand.Read(id) 13 | if err != nil { 14 | panic(err) 15 | } 16 | return fmt.Sprintf("%x", id) 17 | } 18 | 19 | type ProjectedUser struct { 20 | Username string 21 | SessionId string 22 | } 23 | 24 | type AllSessionsInMemory struct { 25 | sessions map[string]*ProjectedUser 26 | } 27 | 28 | func NewAllSessionsInMemory() *AllSessionsInMemory { 29 | return &AllSessionsInMemory{ 30 | sessions: map[string]*ProjectedUser{}, 31 | } 32 | } 33 | 34 | func (self *AllSessionsInMemory) HandleEvent(event *ess.Event) { 35 | switch event.Name { 36 | case "user.logged-in": 37 | if session := event.Payload["session"]; session != nil { 38 | user := &ProjectedUser{ 39 | Username: event.StreamId, 40 | SessionId: session.(string), 41 | } 42 | self.sessions[session.(string)] = user 43 | } 44 | case "user.logged-out": 45 | if session := event.Payload["session"]; session != nil { 46 | delete(self.sessions, session.(string)) 47 | } 48 | } 49 | } 50 | 51 | func (self *AllSessionsInMemory) ById(id string) (*ProjectedUser, error) { 52 | user, found := self.sessions[id] 53 | 54 | if found { 55 | return user, nil 56 | } 57 | 58 | return nil, ErrNotFound 59 | } 60 | -------------------------------------------------------------------------------- /examples/blog/templates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/dhamidi/ess" 8 | ) 9 | 10 | const stylesheet = ` 11 | .menu form { 12 | display: inline-block; 13 | } 14 | 15 | .menu form, .menu form p { 16 | margin: 0px 5px; 17 | } 18 | 19 | .menu a { 20 | text-decoration: none; 21 | color: inherit; 22 | } 23 | ` 24 | 25 | func ShowPost(w http.ResponseWriter, p *ProjectedPost) { 26 | paragraphs := []*HTML{ 27 | H.T("h1", nil, H.Text(p.Title)), 28 | } 29 | for _, paragraph := range p.Paragraphs { 30 | paragraphs = append(paragraphs, H.T("p", nil, H.Text(paragraph))) 31 | } 32 | 33 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 34 | NewHTMLDocument(p.Title, 35 | H.T("article", 36 | H.A("class", "post"), 37 | paragraphs..., 38 | ), 39 | ).WriteHTML(w, "", " ") 40 | } 41 | 42 | func InputField(label, kind, name, value string, errors []string) *HTML { 43 | wrapper := H.T("label", nil, H.Text(label)) 44 | input := H.T("input", 45 | H. 46 | A("type", kind). 47 | A("value", value). 48 | A("name", name), 49 | ) 50 | 51 | if kind == "textarea" { 52 | input = H.T("textarea", H.A("name", name), H.Text(value)) 53 | } 54 | 55 | wrapper.C(input) 56 | 57 | if len(errors) > 0 { 58 | wrapper.C(H.T("em", H.A("class", "errors"), H.Text(strings.Join(errors, ", ")))) 59 | } 60 | 61 | return wrapper 62 | } 63 | 64 | type HTMLFormField struct { 65 | Name string 66 | Kind string 67 | Value string 68 | Label string 69 | Errors []string 70 | } 71 | 72 | type HTMLForm struct { 73 | name string 74 | action string 75 | 76 | fields []*HTMLFormField 77 | params map[string]string 78 | 79 | index map[string]*HTMLFormField 80 | } 81 | 82 | func Form(name, action string, fields ...*HTMLFormField) *HTMLForm { 83 | form := &HTMLForm{ 84 | name: name, 85 | action: action, 86 | fields: fields, 87 | index: map[string]*HTMLFormField{}, 88 | params: map[string]string{}, 89 | } 90 | 91 | for _, field := range fields { 92 | form.index[field.Name] = field 93 | } 94 | 95 | return form 96 | } 97 | 98 | func (self *HTMLForm) Copy() *HTMLForm { 99 | newFields := make([]*HTMLFormField, len(self.fields)) 100 | for i, field := range self.fields { 101 | newField := *field 102 | newFields[i] = &newField 103 | self.index[field.Name] = &newField 104 | } 105 | self.fields = newFields 106 | return self 107 | } 108 | 109 | func (self *HTMLForm) Action(action string) *HTMLForm { 110 | self.action = action 111 | return self 112 | } 113 | 114 | func (self *HTMLForm) Param(name, value string) *HTMLForm { 115 | self.params[name] = value 116 | return self 117 | } 118 | 119 | func (self *HTMLForm) Fill(params *ess.Command, err error) *HTMLForm { 120 | self.Copy() 121 | verr, hasErrors := err.(*ess.ValidationError) 122 | 123 | for name, value := range params.Fields { 124 | field, found := self.index[name] 125 | if !found { 126 | continue 127 | } 128 | 129 | if hasErrors { 130 | field.Errors = verr.Errors[name] 131 | } 132 | 133 | if field.Kind != "password" { 134 | field.Value = value.String() 135 | } 136 | } 137 | 138 | return self 139 | } 140 | 141 | func (self *HTMLForm) ToHTML(submit string) *HTML { 142 | rows := []*HTML{} 143 | 144 | self.addParams(&rows) 145 | 146 | for _, field := range self.fields { 147 | row := H.T("p", nil, InputField(field.Label, field.Kind, field.Name, field.Value, field.Errors)) 148 | rows = append(rows, row) 149 | } 150 | 151 | rows = append(rows, 152 | H.T("p", nil, 153 | H.T("button", H.A("type", "submit"), 154 | H.Text(submit), 155 | ), 156 | ), 157 | ) 158 | 159 | return H.T("form", 160 | H. 161 | A("id", self.name). 162 | A("action", self.action). 163 | A("method", "POST"), 164 | rows..., 165 | ) 166 | } 167 | 168 | func (self *HTMLForm) addParams(rows *[]*HTML) { 169 | if len(self.params) == 0 { 170 | return 171 | } 172 | 173 | row := H.T("p", nil) 174 | for param, value := range self.params { 175 | row.C(H.T("input", 176 | H. 177 | A("type", "hidden"). 178 | A("name", param). 179 | A("value", value), 180 | )) 181 | } 182 | 183 | *rows = append(*rows, row) 184 | } 185 | 186 | var ( 187 | SignUpForm = Form("signup", "/signups", 188 | &HTMLFormField{Label: "Username", Name: "username", Kind: "text"}, 189 | &HTMLFormField{Label: "Email", Name: "email", Kind: "email"}, 190 | &HTMLFormField{Label: "Password", Name: "password", Kind: "password"}, 191 | ) 192 | 193 | LoginForm = Form("login", "/sessions", 194 | &HTMLFormField{Label: "Username", Name: "username", Kind: "text"}, 195 | &HTMLFormField{Label: "Password", Name: "password", Kind: "password"}, 196 | ) 197 | 198 | LogoutForm = Form("logout", "/sessions/logout") 199 | 200 | PostForm = Form("write-post", "/posts", 201 | &HTMLFormField{Label: "Path", Name: "id", Kind: "text"}, 202 | &HTMLFormField{Label: "Title", Name: "title", Kind: "text"}, 203 | &HTMLFormField{Label: "Body", Name: "body", Kind: "textarea"}, 204 | ) 205 | 206 | EditPostForm = Form("edit-post", "/posts/edit", 207 | &HTMLFormField{Label: "Reason", Name: "reason", Kind: "text"}, 208 | &HTMLFormField{Label: "Title", Name: "title", Kind: "text"}, 209 | &HTMLFormField{Label: "Body", Name: "body", Kind: "textarea"}, 210 | ) 211 | ) 212 | 213 | func ShowSignupForm(w http.ResponseWriter) { 214 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 215 | NewHTMLDocument("Sign up", 216 | SignUpForm.ToHTML("Sign up"), 217 | ).WriteHTML(w, "", " ") 218 | } 219 | 220 | func ShowSignupFormErrors(w http.ResponseWriter, params *ess.Command, err error) { 221 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 222 | NewHTMLDocument("Sign up", 223 | SignUpForm.Fill(params, err).ToHTML("Sign up"), 224 | ).WriteHTML(w, "", " ") 225 | } 226 | 227 | func ShowLoginForm(w http.ResponseWriter, req *http.Request) { 228 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 229 | NewHTMLDocument("Log In", 230 | LoginForm.Copy().Param("return", req.FormValue("return")).ToHTML("Log in"), 231 | ).WriteHTML(w, "", " ") 232 | } 233 | 234 | func ShowLoginFormError(w http.ResponseWriter, params *ess.Command, err error) { 235 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 236 | NewHTMLDocument("Log In", 237 | LoginForm.Fill(params, err).ToHTML("Log in"), 238 | ).WriteHTML(w, "", " ") 239 | 240 | } 241 | 242 | func PostOnIndex(post *ProjectedPost, currentUser *ProjectedUser) *HTML { 243 | result := H.T("article", nil, 244 | H.T("em", nil, 245 | H.Text(post.WrittenAt.Format("_2 Jan 2006 "))), 246 | H.Text("by "), 247 | H.T("em", nil, 248 | H.Text(post.Author)), 249 | H.T("a", 250 | H.A("href", post.Path), 251 | H.Text(post.Title), 252 | ), 253 | ) 254 | 255 | if currentUser != nil && post.Author == currentUser.Username { 256 | result.C( 257 | H.T("a", H.A("href", post.Path+"/edit"), 258 | H.T("button", nil, H.Text("Edit"))), 259 | ) 260 | } 261 | 262 | result.C( 263 | H.T("blockquote", nil, 264 | H.T("p", nil, H.Text(post.Paragraphs[0]))), 265 | ) 266 | 267 | return result 268 | } 269 | 270 | func ShowAllPostsIndex(w http.ResponseWriter, currentUser *ProjectedUser, posts []*ProjectedPost) { 271 | menu := H.T("div", H.A("class", "menu")) 272 | if currentUser == nil { 273 | menu.C( 274 | H.T("a", H.A("href", "/sessions"), 275 | H.T("button", nil, H.Text("Log in"))), 276 | H.T("a", H.A("href", "/signups"), 277 | H.T("button", nil, H.Text("Sign up"))), 278 | ) 279 | } else { 280 | menu.C( 281 | H.T("a", H.A("href", "/posts"), 282 | H.T("button", nil, H.Text("Write post"))), 283 | LogoutForm.ToHTML("Log out"), 284 | ) 285 | } 286 | 287 | body := H.T("ul", nil) 288 | for _, post := range posts { 289 | body.C(H.T("li", nil, PostOnIndex(post, currentUser))) 290 | } 291 | 292 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 293 | NewHTMLDocument("Recent posts", 294 | menu, 295 | H.T("h1", nil, H.Text("Recent posts")), 296 | body, 297 | ).WriteHTML(w, "", " ") 298 | } 299 | 300 | func ShowPostForm(w http.ResponseWriter) { 301 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 302 | NewHTMLDocument("Write Post", 303 | PostForm.ToHTML("Write post"), 304 | ).WriteHTML(w, "", " ") 305 | 306 | } 307 | 308 | func ShowPostFormError(w http.ResponseWriter, params *ess.Command, err error) { 309 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 310 | NewHTMLDocument("Write Post", 311 | PostForm.Fill(params, err).ToHTML("Write post"), 312 | ).WriteHTML(w, "", " ") 313 | 314 | } 315 | 316 | func ShowEditPostForm(w http.ResponseWriter, params *ess.Command) { 317 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 318 | NewHTMLDocument("Edit Post", 319 | EditPostForm. 320 | Fill(params, nil). 321 | Action("/posts/"+params.AggregateId()+"/edit"). 322 | Param("id", params.AggregateId()). 323 | Param("username", params.Get("username").String()). 324 | ToHTML("Edit post"), 325 | ).WriteHTML(w, "", " ") 326 | 327 | } 328 | 329 | func ShowEditPostFormError(w http.ResponseWriter, params *ess.Command, err error) { 330 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 331 | NewHTMLDocument("Edit Post", 332 | EditPostForm. 333 | Fill(params, err). 334 | Action("/posts/"+params.AggregateId()+"/edit"). 335 | Param("id", params.AggregateId()). 336 | Param("username", params.Get("username").String()). 337 | ToHTML("Edit post"), 338 | ).WriteHTML(w, "", " ") 339 | 340 | } 341 | -------------------------------------------------------------------------------- /examples/blog/user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/dhamidi/ess" 4 | 5 | type User struct { 6 | id string 7 | events ess.EventPublisher 8 | 9 | signedUp bool 10 | password string 11 | activeSessions map[string]bool 12 | } 13 | 14 | func NewUser(id string) *User { 15 | return &User{ 16 | id: id, 17 | activeSessions: map[string]bool{}, 18 | } 19 | } 20 | 21 | func (self *User) HandleCommand(command *ess.Command) error { 22 | switch command.Name { 23 | case "sign-up": 24 | return self.SignUp(command) 25 | case "login": 26 | return self.Login(command.Get("session").String(), command.Get("password").(*ess.BcryptedPassword)) 27 | case "logout": 28 | return self.Logout(command.Get("session").String()) 29 | } 30 | return nil 31 | } 32 | 33 | func (self *User) SignUp(params *ess.Command) error { 34 | err := ess.NewValidationError() 35 | 36 | if self.signedUp { 37 | err.Add("username", "not_unique") 38 | } 39 | 40 | if err.Ok() { 41 | self.events.PublishEvent( 42 | ess.NewEvent("user.signed-up"). 43 | For(self). 44 | Add("username", params.Get("username").String()). 45 | Add("password", params.Get("password").String()). 46 | Add("email", params.Get("email").String()), 47 | ) 48 | } 49 | 50 | return err.Return() 51 | } 52 | 53 | func (self *User) Login(session string, password *ess.BcryptedPassword) error { 54 | err := ess.NewValidationError() 55 | 56 | if !self.signedUp { 57 | err.Add("user", "not_found") 58 | } 59 | 60 | if !password.Matches(self.password) { 61 | err.Add("password", "mismatch") 62 | } 63 | 64 | if err.Ok() { 65 | self.events.PublishEvent( 66 | ess.NewEvent("user.logged-in"). 67 | For(self). 68 | Add("session", session), 69 | ) 70 | } 71 | 72 | return err.Return() 73 | } 74 | 75 | func (self *User) Logout(session string) error { 76 | err := ess.NewValidationError() 77 | 78 | if !self.signedUp { 79 | err.Add("user", "not_found") 80 | } 81 | 82 | if session == "" { 83 | err.Add("session", "empty") 84 | } 85 | 86 | if !self.HasActiveSession(session) { 87 | err.Add("session", "expired") 88 | } 89 | 90 | if err.Ok() { 91 | self.events.PublishEvent( 92 | ess.NewEvent("user.logged-out"). 93 | For(self). 94 | Add("session", session), 95 | ) 96 | } 97 | 98 | return err.Return() 99 | } 100 | 101 | func (self *User) HasActiveSession(session string) bool { 102 | _, found := self.activeSessions[session] 103 | return found 104 | } 105 | 106 | func (self *User) HandleEvent(event *ess.Event) { 107 | switch event.Name { 108 | case "user.signed-up": 109 | self.signedUp = true 110 | self.password = event.Payload["password"].(string) 111 | case "user.logged-in": 112 | if session := event.Payload["session"]; session != nil { 113 | self.activeSessions[session.(string)] = true 114 | } 115 | } 116 | } 117 | 118 | func (self *User) Id() string { 119 | return self.id 120 | } 121 | 122 | func (self *User) PublishWith(events ess.EventPublisher) ess.Aggregate { 123 | self.events = events 124 | return self 125 | } 126 | -------------------------------------------------------------------------------- /examples/signup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/dhamidi/ess" 9 | ) 10 | 11 | var SignUp = ess.NewCommandDefinition("sign-up"). 12 | Id("username", ess.Id()). 13 | Field("name", ess.TrimmedString()). 14 | Field("email", ess.EmailAddress()). 15 | Field("password", ess.Password()). 16 | Target(NewUserFromCommand) 17 | 18 | func NewUserFromCommand(command *ess.Command) ess.Aggregate { 19 | return NewUser(command.Get("username").String()) 20 | } 21 | 22 | type User struct { 23 | id string 24 | events ess.EventPublisher 25 | signedUp bool 26 | } 27 | 28 | func NewUser(username string) *User { 29 | return &User{ 30 | id: username, 31 | } 32 | } 33 | 34 | func (self *User) PublishWith(publisher ess.EventPublisher) ess.Aggregate { 35 | self.events = publisher 36 | return self 37 | } 38 | 39 | func (self *User) Id() string { 40 | return self.id 41 | } 42 | 43 | func (self *User) HandleCommand(command *ess.Command) error { 44 | switch command.Name { 45 | case "sign-up": 46 | return self.SignUp( 47 | command.Get("name").String(), 48 | command.Get("email").String(), 49 | command.Get("password").String(), 50 | ) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (self *User) SignUp(name, email, password string) error { 57 | err := ess.NewValidationError() 58 | 59 | if self.signedUp { 60 | err.Add("username", "not_unique") 61 | } 62 | 63 | if password == "" { 64 | err.Add("password", "empty") 65 | } 66 | 67 | if email == "" { 68 | err.Add("email", "empty") 69 | } 70 | 71 | if err.Ok() { 72 | self.events.PublishEvent( 73 | ess.NewEvent("user.signed-up"). 74 | For(self). 75 | Add("password", password). 76 | Add("email", email). 77 | Add("name", name), 78 | ) 79 | } 80 | 81 | return err.Return() 82 | } 83 | 84 | func (self *User) HandleEvent(event *ess.Event) { 85 | switch event.Name { 86 | case "user.signed-up": 87 | self.signedUp = true 88 | } 89 | } 90 | 91 | func main() { 92 | app := ess.NewApplication("user-signup-example") 93 | 94 | http.HandleFunc("/", ShowSignupForm) 95 | http.Handle("/signups", HandleSignup(app)) 96 | log.Fatal(http.ListenAndServe("localhost:8080", nil)) 97 | } 98 | 99 | func ShowSignupForm(w http.ResponseWriter, req *http.Request) { 100 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 101 | fmt.Fprintf(w, `Signup 102 | 103 |

Sign up

104 |
105 |

106 | 107 | 108 |

109 |

110 | 111 | 112 |

113 |

114 | 115 | 116 |

117 |

118 | 119 | 120 |

121 |

122 | 123 |

124 |
125 | `) 126 | } 127 | 128 | func HandleSignup(app *ess.Application) http.Handler { 129 | handler := func(w http.ResponseWriter, req *http.Request) { 130 | command := SignUp.FromForm(req) 131 | result := app.Send(command) 132 | 133 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 134 | if err := result.Error(); err != nil { 135 | fmt.Fprintf(w, "Errors: %s\n", err) 136 | } else { 137 | fmt.Fprintf(w, "Signed up successfully.\n") 138 | } 139 | } 140 | return http.HandlerFunc(handler) 141 | } 142 | -------------------------------------------------------------------------------- /examples/signup/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dhamidi/ess" 7 | ) 8 | 9 | func TestUser_SignUp_failsIfUserHasSignedUpAlready(t *testing.T) { 10 | password := ess.Password() 11 | password.UnmarshalText([]byte("password")) 12 | 13 | history := []*ess.Event{ 14 | ess.NewEvent("user.signed-up"). 15 | Add("username", "test-user"). 16 | Add("password", password.String()). 17 | Add("email", "test@example.com"). 18 | Add("name", "John Doe"), 19 | } 20 | 21 | user := NewUser("username") 22 | for _, event := range history { 23 | user.HandleEvent(event) 24 | } 25 | 26 | err := user.SignUp("Jane Doe", "jane.doe@example.com", password.String()) 27 | if err == nil { 28 | t.Fatal("Expected an error") 29 | } 30 | 31 | verr, ok := err.(*ess.ValidationError) 32 | if !ok { 33 | t.Fatalf("err.(type) = %T; want %T", err, verr) 34 | } 35 | 36 | if got, want := len(verr.Errors["username"]), 1; got != want { 37 | t.Errorf(`len(verr.Errors["username"]) = %v; want %v`, got, want) 38 | } 39 | 40 | if got, want := verr.Errors["username"][0], "not_unique"; got != want { 41 | t.Errorf(`verr.Errors["username"][0] = %v; want %v`, got, want) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /interfaces.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "encoding" 5 | "time" 6 | ) 7 | 8 | // Aggregate defines the necessary methods for acting interacting with 9 | // an event sourced application. 10 | // 11 | // Aggregates receives requests for state changes in the form of 12 | // commands and emit events via an EventPublisher. In order to 13 | // reconstruct an aggregate's state, it needs to be able to handle 14 | // events as well. 15 | type Aggregate interface { 16 | // Id returns a string uniquely identifying the aggregate. 17 | // The aggregate's id is used for routing commands to it and 18 | // associating emitted events with this aggregate. 19 | Id() string 20 | 21 | // PublishWith configures the aggregate to emit events using 22 | // publisher. 23 | PublishWith(publisher EventPublisher) Aggregate 24 | 25 | CommandHandler 26 | EventHandler 27 | } 28 | 29 | // Clock is an interface for providing the current time. 30 | // 31 | // This interface exists to decouple objects that need access to the 32 | // current time from the system time. Mainly used for testing. 33 | type Clock interface { 34 | Now() time.Time 35 | } 36 | 37 | // Value is defines the interface for converting text into Go values. 38 | // A value is used for capturing, sanitizing and validating the 39 | // parameters accepted by commands. 40 | type Value interface { 41 | encoding.TextUnmarshaler 42 | 43 | // String should convert the value to a string representing 44 | // this value's data. 45 | String() string 46 | 47 | // Copy creates a new instance with the same internal state as 48 | // this value instance. 49 | Copy() Value 50 | } 51 | 52 | // EventPublisher defines the interface for publishing events in 53 | // aggregates. 54 | type EventPublisher interface { 55 | // PublishEvent queues event for publishing. 56 | PublishEvent(event *Event) EventPublisher 57 | } 58 | 59 | // CommandHandler defines the interface for handling commands. 60 | type CommandHandler interface { 61 | // HandleCommand tries to process command. If command cannot 62 | // be processed due to a validation of business rules, a 63 | // *ValidationError should be returned. In that case, no 64 | // event should be emitted. 65 | HandleCommand(command *Command) error 66 | } 67 | 68 | // EventHandler defines the interface for processing events. 69 | type EventHandler interface { 70 | HandleEvent(event *Event) 71 | } 72 | 73 | // EventHandlerFunc is a wrapper type to allow a function to fulfull 74 | // the EventHandler interface by calling the function. 75 | type EventHandlerFunc func(event *Event) 76 | 77 | // HandleEvent implements the EventHandler interface. 78 | func (self EventHandlerFunc) HandleEvent(event *Event) { self(event) } 79 | 80 | // EventStore defines the necessary operations for persisting events 81 | // and restoring application state from the log of persisted events. 82 | type EventStore interface { 83 | // Store append events to the store in a manner that allows 84 | // them to be retrieved by Replay. The returned error is 85 | // implementation defined. 86 | Store(events []*Event) error 87 | 88 | // Replay replays the events belonging to the stream 89 | // identified by streamId using receiver. 90 | // 91 | // Using "*" as the streamId select all events, regardless of 92 | // the event's actual stream id. 93 | // 94 | // Any error returned is implementation defined. 95 | Replay(streamId string, receiver EventHandler) error 96 | } 97 | 98 | // Form defines how to access form values. This allows commands to 99 | // fill in parameters automatically. 100 | // 101 | // This interface is modelled after net/http.Request so that http 102 | // requests can be used where a Form is required. 103 | type Form interface { 104 | // FromValue returns the string value associated with the form 105 | // field "field". 106 | FormValue(field string) string 107 | } 108 | -------------------------------------------------------------------------------- /test_aggregate.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | // testAggregate is a dummy aggregate used for testing. This type is 4 | // included in the main package, because it is referenced by 5 | // EventStoreTest. 6 | type testAggregate struct { 7 | id string 8 | events EventPublisher 9 | error error 10 | 11 | onEvent func(event *Event) 12 | onCommand func(*testAggregate) 13 | } 14 | 15 | func newTestAggregateFromCommand(command *Command) Aggregate { 16 | return newTestAggregate(command.Get("id").String()) 17 | } 18 | 19 | func newTestAggregate(id string) *testAggregate { 20 | return &testAggregate{id: id} 21 | } 22 | 23 | func (self *testAggregate) FailWith(err error) *testAggregate { 24 | self.error = err 25 | return self 26 | } 27 | 28 | func (self *testAggregate) Id() string { 29 | return self.id 30 | } 31 | 32 | func (self *testAggregate) HandleEvent(e *Event) { 33 | if self.onEvent != nil { 34 | self.onEvent(e) 35 | } 36 | } 37 | 38 | func (self *testAggregate) HandleCommand(command *Command) error { 39 | if self.onCommand != nil { 40 | self.onCommand(self) 41 | } 42 | return self.error 43 | } 44 | 45 | func (self *testAggregate) PublishWith(publisher EventPublisher) Aggregate { 46 | self.events = publisher 47 | return self 48 | } 49 | -------------------------------------------------------------------------------- /validation_error.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | // ValidationError captures errors about the values of a command's 9 | // parameter or the state of a whole aggregate. 10 | // 11 | // Return errors of this type by the methods handling commands in your 12 | // aggregates. 13 | type ValidationError struct { 14 | Errors map[string][]string `json:"error"` 15 | } 16 | 17 | // NewValidationError returns a new, empty validation error. 18 | func NewValidationError() *ValidationError { 19 | return &ValidationError{ 20 | Errors: map[string][]string{}, 21 | } 22 | } 23 | 24 | // Ok returns true if no errors have been recorded with this instance. 25 | func (self *ValidationError) Ok() bool { return len(self.Errors) == 0 } 26 | 27 | // Add records an error for field using desc as the error description. 28 | func (self *ValidationError) Add(field string, desc string) *ValidationError { 29 | self.Errors[field] = append(self.Errors[field], desc) 30 | return self 31 | } 32 | 33 | // Merge records errors from err into this instance. 34 | // 35 | // If err is a ValidationError, all recorded errors for all fields 36 | // from err are merged into this instance. 37 | // 38 | // Otherwise err's string representation is recorded in the field 39 | // $all. 40 | func (self *ValidationError) Merge(err error) *ValidationError { 41 | verr, ok := err.(*ValidationError) 42 | if !ok { 43 | return self.Add("$all", err.Error()) 44 | } 45 | 46 | for field, errors := range verr.Errors { 47 | self.Errors[field] = append(self.Errors[field], errors...) 48 | } 49 | 50 | return self 51 | } 52 | 53 | // Return returns nil if no errors have been recorded with this 54 | // instance. Otherwise this instance is returned. 55 | // 56 | // This method exists to avoid returning a typed nil accidentally. 57 | // 58 | // Example: 59 | // 60 | // func (obj *MyDomainObject) DoSomething(param string) error { 61 | // err := NewValidationError() 62 | // if param == "" { 63 | // err.Add("param", "empty") 64 | // } 65 | // return err.Return() 66 | // } 67 | func (self *ValidationError) Return() error { 68 | if len(self.Errors) == 0 { 69 | return nil 70 | } else { 71 | return self 72 | } 73 | } 74 | 75 | // Error implements the error interface. 76 | func (self *ValidationError) Error() string { 77 | out := new(bytes.Buffer) 78 | for field, errors := range self.Errors { 79 | fmt.Fprintf(out, "%s: ", field) 80 | for i, desc := range errors { 81 | fmt.Fprintf(out, "%s", desc) 82 | if i < len(errors)-1 { 83 | fmt.Fprintf(out, ", ") 84 | } 85 | } 86 | fmt.Fprintf(out, "; ") 87 | } 88 | return out.String() 89 | } 90 | -------------------------------------------------------------------------------- /validation_error_test.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestValidationError_Ok_returnsTrueIfThereAreNoErrors(t *testing.T) { 9 | err := NewValidationError() 10 | if got, want := err.Ok(), true; got != want { 11 | t.Errorf(`err.Ok() = %v; want %v`, got, want) 12 | } 13 | } 14 | 15 | func TestValidationError_Ok_returnsFalseIfThereAreErrors(t *testing.T) { 16 | err := NewValidationError().Add("test", "error") 17 | if got, want := err.Ok(), false; got != want { 18 | t.Errorf(`err.Ok() = %v; want %v`, got, want) 19 | } 20 | } 21 | 22 | func TestValidationError_Merge_mergesOtherErrorTypeUnderKeyAll(t *testing.T) { 23 | testError := errors.New("test error") 24 | err := NewValidationError().Merge(testError) 25 | if got, want := len(err.Errors["$all"]), 1; got != want { 26 | t.Errorf(`len(err.Errors["$all"]) = %v; want %v`, got, want) 27 | } 28 | 29 | if got, want := err.Errors["$all"][0], testError.Error(); got != want { 30 | t.Errorf(`err.Errors["$all"][0] = %v; want %v`, got, want) 31 | } 32 | } 33 | 34 | func TestValidationError_Return_returnsNilIfErrorIsOk(t *testing.T) { 35 | err := NewValidationError() 36 | if got, want := err.Return(), (error)(nil); got != want { 37 | t.Errorf(`err.Return() = %v; want %v`, got, want) 38 | } 39 | } 40 | 41 | func TestValidationError_Return_returnsSelfIfErrorIsNotOk(t *testing.T) { 42 | err := NewValidationError().Add("field", "error") 43 | if got, want := err.Return(), err; got != want { 44 | t.Errorf(`err.Return() = %v; want %v`, got, want) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /values.go: -------------------------------------------------------------------------------- 1 | package ess 2 | 3 | import ( 4 | "errors" 5 | "net/mail" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "golang.org/x/crypto/bcrypt" 11 | ) 12 | 13 | // String is an implementation of Value for handling string 14 | // parameters. 15 | type String struct { 16 | original string 17 | sanitized string 18 | sanitizer func(string) string 19 | } 20 | 21 | // TrimmedString constructs a string value which removes initial and 22 | // trailing whitespace from the initial string. 23 | func TrimmedString() *String { 24 | return &String{ 25 | sanitizer: strings.TrimSpace, 26 | } 27 | } 28 | 29 | // StringValue constructs a string which returns str when calling its 30 | // String method. 31 | func StringValue(str string) *String { 32 | return &String{ 33 | sanitized: str, 34 | sanitizer: func(s string) string { return s }, 35 | } 36 | } 37 | 38 | // UnmarshalText accepts data as the string's content and applies and 39 | // internal sanitization function to data. 40 | func (self *String) UnmarshalText(data []byte) error { 41 | self.original = string(data) 42 | self.sanitized = self.sanitizer(self.original) 43 | return nil 44 | } 45 | 46 | func (self *String) String() string { 47 | return self.sanitized 48 | } 49 | 50 | func (self *String) Copy() Value { 51 | return &String{ 52 | sanitized: self.sanitized, 53 | original: self.original, 54 | sanitizer: self.sanitizer, 55 | } 56 | } 57 | 58 | // Time is an implementation of Value for handling timestamps. It 59 | // works with timestamps formatted according to time.RFC3339Nano. 60 | type Time struct { 61 | time.Time 62 | } 63 | 64 | func (self Time) String() string { 65 | data, _ := self.Time.MarshalText() 66 | return string(data) 67 | } 68 | 69 | func (self Time) Copy() Value { 70 | return &Time{self.Time} 71 | } 72 | 73 | var ( 74 | identifierRegexp = regexp.MustCompile(`^[-a-z0-9]+$`) 75 | 76 | // ErrMalformedIdentifier is returned when parsing an 77 | // identifier fails. 78 | ErrMalformedIdentifier = errors.New(`malformed_identifier`) 79 | 80 | // ErrEmpty is returned when a non-empty input string is 81 | // expected. 82 | ErrEmpty = errors.New("empty") 83 | ) 84 | 85 | // Identifier is a value for handling parameters that serve as 86 | // identifiers. It accepts any string consisting only of dashes, 87 | // lowercase letters and digits. 88 | // 89 | // The empty string is not a valid identifier. 90 | type Identifier struct { 91 | id string 92 | } 93 | 94 | // Id returns a new empty identifier. 95 | func Id() *Identifier { 96 | return &Identifier{} 97 | } 98 | 99 | // UnmarshalText returns ErrMalformedIdentifier identifier is data is 100 | // not a valid identifier. 101 | func (self *Identifier) UnmarshalText(data []byte) error { 102 | id := strings.TrimSpace(string(data)) 103 | if !identifierRegexp.MatchString(id) { 104 | return ErrMalformedIdentifier 105 | } 106 | 107 | self.id = id 108 | return nil 109 | } 110 | 111 | func (self *Identifier) String() string { 112 | return self.id 113 | } 114 | 115 | func (self *Identifier) Copy() Value { 116 | return &Identifier{id: self.id} 117 | } 118 | 119 | // Email is an implementation of value for handling email addresses. 120 | // It parses email addresses according to RFC 5322, e.g. "Barry Gibbs 121 | // ". 122 | type Email struct { 123 | address *mail.Address 124 | } 125 | 126 | func (self *Email) UnmarshalText(data []byte) error { 127 | address, err := mail.ParseAddress(string(data)) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | self.address = address 133 | return nil 134 | } 135 | 136 | func (self *Email) String() string { 137 | if self.address != nil { 138 | return self.address.Address 139 | } 140 | 141 | return "" 142 | } 143 | 144 | func (self *Email) Copy() Value { 145 | return &Email{address: self.address} 146 | } 147 | 148 | // EmailAddress returns a new, empty email value. 149 | func EmailAddress() *Email { return &Email{} } 150 | 151 | // BcryptedPassword is an implementation for securely handling 152 | // password parameters. It uses the bcrypt algorithm for hashing 153 | // passwords. 154 | type BcryptedPassword struct { 155 | plain []byte 156 | bytes []byte 157 | } 158 | 159 | // UnmarshalText generates a password from data using bcrypt. It 160 | // returns ErrEmpty is data is empty. 161 | func (self *BcryptedPassword) UnmarshalText(data []byte) error { 162 | if len(data) == 0 { 163 | return ErrEmpty 164 | } 165 | bytes, err := bcrypt.GenerateFromPassword(data, bcrypt.DefaultCost) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | self.plain = append(self.plain, data...) 171 | self.bytes = bytes 172 | return nil 173 | } 174 | 175 | // Copy copies the password. The copy does not contain the password's 176 | // plain text anymore. 177 | func (self *BcryptedPassword) Copy() Value { return &BcryptedPassword{bytes: self.bytes} } 178 | 179 | // String returns the hashed password as a string. 180 | func (self *BcryptedPassword) String() string { return string(self.bytes) } 181 | 182 | // Matches returns true if this password matches hashedPassword. 183 | func (self *BcryptedPassword) Matches(hashedPassword string) bool { 184 | return bcrypt.CompareHashAndPassword([]byte(hashedPassword), self.plain) == nil 185 | } 186 | 187 | // Password returns a new, empty BcryptedPassword. 188 | func Password() *BcryptedPassword { return &BcryptedPassword{} } 189 | --------------------------------------------------------------------------------