├── .gitignore ├── Makefile ├── README.org ├── api ├── error-response.go ├── error.go ├── interface.go ├── json_response.go ├── request.go └── web.go ├── bus ├── log.go └── memory.go ├── env ├── container.go └── interface.go ├── event.go ├── hosting ├── context.go └── wire.go ├── id.go ├── log └── setup.go ├── spec ├── Makefile ├── context.go ├── marshal.go ├── publisher.go ├── report.go ├── sanity.go ├── syntax_then_events.go ├── syntax_then_response.go ├── syntax_when_request.go ├── syntax_where.go └── verify.go └── watch-and-run.sh /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | bin 3 | host/host 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # force rebuild on these targets 2 | .PHONY: build run 3 | 4 | # default build 5 | default: build 6 | 7 | build: 8 | go build -v -o ./bin/host ./host/main.go 9 | 10 | run: build 11 | ./bin/host 12 | 13 | 14 | reset: 15 | go run ./host/reset.go 16 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | * Omni - Work in progress 2 | 3 | This is a prototype library in golang for implementing event-driven 4 | micro-services with the support for cluster simulation. See [[https://github.com/abdullin/anthill][anthill]] 5 | project for more details. 6 | 7 | If you are looking for the original event-driven GTD sample check out 8 | [[https://github.com/abdullin/omni/tree/gtd][gtd]] tag in the repository. 9 | 10 | * Folder Structure 11 | 12 | # core - event-driven infrastructure and specs 13 | 14 | This folder contains core infrastructure for prototyping event-driven 15 | back-ends. You can import it in your go and move from there. 16 | 17 | - =root= - binary-sortable UUID and a definition of an event 18 | - =api= - logic for hosting a simple JSON API (with some helpers) 19 | - =bus= - event bus and an in-memory implementation 20 | - =log= - helpers to setup logging 21 | - =env= - environment for defining modules and specs (contracts) 22 | - =specs= - express, verify and print event-driven specifications 23 | - =hosting= - wire and run modules in a process 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /api/error-response.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type errorResponse struct { 9 | Code int 10 | Error string 11 | } 12 | 13 | func (e *errorResponse) Write(w http.ResponseWriter) { 14 | 15 | type errDto struct { 16 | Error string `json:"error"` 17 | } 18 | w.Header().Set("Content-Type", "application/json") 19 | b, err := json.Marshal(&errDto{e.Error}) 20 | guard("marhsal", err) 21 | w.WriteHeader(e.Code) 22 | w.Write(b) 23 | } 24 | -------------------------------------------------------------------------------- /api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "runtime/debug" 7 | ) 8 | 9 | func handleError(w http.ResponseWriter, err error) { 10 | switch err := err.(type) { 11 | case *ErrArgument: 12 | NewError(err.Error(), http.StatusBadRequest).Write(w) 13 | default: 14 | NewError(err.Error(), http.StatusInternalServerError).Write(w) 15 | l.Error("ERR : %v.\n%s", err.Error(), debug.Stack()) 16 | } 17 | } 18 | 19 | func NewError(error string, code int) Response { 20 | return &errorResponse{code, error} 21 | } 22 | 23 | func BadRequestResponse(err string) Response { 24 | return &errorResponse{ 25 | Code: http.StatusBadRequest, 26 | Error: err, 27 | } 28 | } 29 | 30 | func NotImplementedResponse() Response { 31 | return &errorResponse{ 32 | Code: http.StatusInternalServerError, 33 | Error: "Not implemented", 34 | } 35 | } 36 | 37 | type ErrArgument struct { 38 | Argument string 39 | Problem string 40 | } 41 | 42 | func (e *ErrArgument) Error() string { 43 | return fmt.Sprintf("Argument '%v': %v", e.Argument, e.Problem) 44 | } 45 | 46 | func NewErrArgumentNil(param string) *ErrArgument { 47 | return &ErrArgument{param, "can't be empty"} 48 | } 49 | 50 | func NewErrArgument(param, problem string) *ErrArgument { 51 | return &ErrArgument{param, problem} 52 | } 53 | -------------------------------------------------------------------------------- /api/interface.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | 5 | type Handler func(r *Request) Response 6 | 7 | type Route struct { 8 | Method string 9 | Path string 10 | Handler Handler 11 | } 12 | 13 | func NewRoute(method, path string, handler Handler) *Route { 14 | return &Route{method, path, handler} 15 | } 16 | 17 | type Response interface { 18 | Write(w http.ResponseWriter) 19 | } 20 | -------------------------------------------------------------------------------- /api/json_response.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type jsonResponse struct { 9 | subj interface{} 10 | Code int 11 | } 12 | 13 | func NewJSON(subj interface{}) Response { 14 | return &jsonResponse{subj, 200} 15 | } 16 | 17 | func (e *jsonResponse) Write(w http.ResponseWriter) { 18 | 19 | w.Header().Set("Content-Type", "application/json") 20 | b, err := json.Marshal(e.subj) 21 | guard("marhsal", err) 22 | w.WriteHeader(e.Code) 23 | w.Write(b) 24 | } 25 | -------------------------------------------------------------------------------- /api/request.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | ) 8 | 9 | type Request struct { 10 | Raw *http.Request 11 | } 12 | 13 | func NewRequest(inner *http.Request) *Request { 14 | return &Request{inner} 15 | } 16 | 17 | func (r *Request) String(param string) string { 18 | if v := r.Raw.FormValue(param); v == "" { 19 | panic(NewErrArgumentNil(param)) 20 | } else { 21 | return v 22 | } 23 | } 24 | 25 | func (r *Request) ParseBody(subj interface{}) error { 26 | content := r.Raw.Header.Get("Content-Type") 27 | switch content { 28 | case "application/json": 29 | decoder := json.NewDecoder(r.Raw.Body) 30 | return decoder.Decode(subj) 31 | 32 | } 33 | return errors.New("Unexpected content type " + content) 34 | 35 | } 36 | -------------------------------------------------------------------------------- /api/web.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/op/go-logging" 8 | ) 9 | 10 | var ( 11 | l = logging.MustGetLogger("api") 12 | ) 13 | 14 | func guard(name string, err error) { 15 | if err != nil { 16 | l.Panicf("%s: %s", name, err) 17 | } 18 | } 19 | 20 | func Handle(router *mux.Router, route *Route) *mux.Route { 21 | var handler = func(w http.ResponseWriter, req *http.Request) { 22 | defer func() { 23 | if r := recover(); r != nil { 24 | if err, ok := r.(error); ok { 25 | handleError(w, err) 26 | return 27 | } 28 | panic(r) 29 | } 30 | }() 31 | 32 | // TODO: setup request 33 | var request = NewRequest(req) 34 | var response = route.Handler(request) 35 | response.Write(w) 36 | } 37 | 38 | return router.Methods(route.Method).Subrouter().HandleFunc(route.Path, handler) 39 | } 40 | -------------------------------------------------------------------------------- /bus/log.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/abdullin/omni" 7 | "github.com/abdullin/omni/env" 8 | "github.com/op/go-logging" 9 | ) 10 | 11 | var ( 12 | l = logging.MustGetLogger("bus") 13 | ) 14 | 15 | type LoggingWrapper struct { 16 | inner env.Publisher 17 | } 18 | 19 | func WrapWithLogging(p env.Publisher) env.Publisher { 20 | return &LoggingWrapper{p} 21 | } 22 | 23 | func log(es ...core.Event) { 24 | for _, e := range es { 25 | switch e := e.(type) { 26 | case fmt.Stringer: 27 | l.Debug("%v", e.String()) 28 | default: 29 | var contract = e.Meta().Contract 30 | l.Debug("%v", contract) 31 | } 32 | } 33 | } 34 | 35 | func (p *LoggingWrapper) Publish(e ...core.Event) error { 36 | log(e...) 37 | return p.inner.Publish(e...) 38 | } 39 | func (p *LoggingWrapper) MustPublish(e ...core.Event) { 40 | log(e...) 41 | p.inner.MustPublish(e...) 42 | } 43 | -------------------------------------------------------------------------------- /bus/memory.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | 8 | "github.com/abdullin/omni" 9 | "github.com/abdullin/omni/env" 10 | ) 11 | 12 | type mem struct { 13 | c chan core.Event 14 | handlers map[string]env.EventHandler 15 | started bool 16 | 17 | mu sync.Mutex 18 | } 19 | 20 | func (m *mem) AddEventHandler(name string, h env.EventHandler) { 21 | m.handlers[name] = h 22 | } 23 | 24 | func (m *mem) MustPublish(es ...core.Event) { 25 | for _, e := range es { 26 | m.c <- e 27 | } 28 | } 29 | func (m *mem) Publish(es ...core.Event) error { 30 | m.MustPublish(es...) 31 | return nil 32 | } 33 | 34 | func (m *mem) Start() { 35 | if m.started { 36 | panic("Bus can't be started twice") 37 | } 38 | 39 | m.started = true 40 | 41 | go func() { 42 | for { 43 | select { 44 | case message := <-m.c: 45 | m.dispatch(message) 46 | } 47 | } 48 | }() 49 | } 50 | 51 | func (m *mem) dispatch(e core.Event) { 52 | for name, h := range m.handlers { 53 | if err := handleWithTimeout(h, e); err != nil { 54 | var contract = e.Meta().Contract 55 | l.Panicf("%s @ %s: %s", name, contract, err) 56 | } 57 | } 58 | } 59 | 60 | func handleWithTimeout(h env.EventHandler, e core.Event) (err error) { 61 | c := make(chan error, 1) 62 | 63 | defer func() { 64 | if r := recover(); r != nil { 65 | err = r.(error) 66 | } 67 | }() 68 | 69 | go func() { 70 | c <- h.HandleEvent(e) 71 | }() 72 | 73 | select { 74 | case err = <-c: 75 | return 76 | 77 | case <-time.After(time.Second): 78 | err = errors.New("Timeout") 79 | } 80 | return 81 | } 82 | 83 | type Bus interface { 84 | AddEventHandler(name string, h env.EventHandler) 85 | env.Publisher 86 | Start() 87 | } 88 | 89 | func NewMem() Bus { 90 | return &mem{ 91 | c: make(chan core.Event, 10000), 92 | handlers: make(map[string]env.EventHandler), 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /env/container.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "github.com/abdullin/omni/api" 5 | ) 6 | 7 | func NewContainer() *Container { 8 | return &Container{ 9 | Routes: []*api.Route{}, 10 | Handlers: EventHandlerMap{}, 11 | DataReset: make(map[string]func()), 12 | } 13 | } 14 | 15 | type Container struct { 16 | Routes []*api.Route 17 | Handlers EventHandlerMap 18 | DataReset map[string]func() 19 | } 20 | 21 | func (r *Container) HandleHttp( 22 | method string, 23 | path string, 24 | handler api.Handler) { 25 | r.Routes = append(r.Routes, api.NewRoute(method, path, handler)) 26 | } 27 | 28 | func (r *Container) HandleEvents( 29 | name string, 30 | handler EventHandler, 31 | ) { 32 | r.Handlers[name] = handler 33 | } 34 | 35 | func (r *Container) ResetData(name string, reset func()) { 36 | r.DataReset[name] = reset 37 | } 38 | -------------------------------------------------------------------------------- /env/interface.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/abdullin/omni" 7 | "github.com/abdullin/omni/api" 8 | ) 9 | 10 | type Registrar interface { 11 | HandleHttp(method string, path string, handler api.Handler) 12 | HandleEvents(name string, handler EventHandler) 13 | ResetData(name string, action func()) 14 | } 15 | 16 | type Module interface { 17 | Register(r Registrar) 18 | // Spec() *Spec 19 | } 20 | 21 | type Spec struct { 22 | Name string 23 | //Factory Factory 24 | UseCases []UseCaseFactory 25 | } 26 | 27 | type UseCaseFactory func() *UseCase 28 | 29 | type EventHandler interface { 30 | Contracts() []string 31 | HandleEvent(e core.Event) error 32 | } 33 | 34 | type EventHandlerMap map[string]EventHandler 35 | 36 | type Publisher interface { 37 | Publish(e ...core.Event) error 38 | MustPublish(e ...core.Event) 39 | } 40 | 41 | type Request struct { 42 | Method string 43 | Path string 44 | Headers http.Header 45 | Body interface{} 46 | } 47 | 48 | type Response struct { 49 | Status int `json:"status"` 50 | Headers http.Header `json:"headers"` 51 | Body interface{} `json:"body"` 52 | } 53 | 54 | type UseCase struct { 55 | Name string 56 | 57 | Given []core.Event 58 | When *Request 59 | 60 | ThenEvents []core.Event 61 | ThenResponse *Response 62 | 63 | Where Where 64 | } 65 | 66 | type Where interface { 67 | Map() map[string]string 68 | } 69 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | type Event interface { 4 | Meta() *Info 5 | } 6 | 7 | type Contract string 8 | 9 | type Info struct { 10 | Contract string 11 | EventId Id 12 | } 13 | 14 | func NewInfo(contract string, eventId Id) *Info { 15 | return &Info{contract, eventId} 16 | } 17 | -------------------------------------------------------------------------------- /hosting/context.go: -------------------------------------------------------------------------------- 1 | package hosting 2 | 3 | import ( 4 | "github.com/abdullin/omni/api" 5 | 6 | "github.com/abdullin/omni/bus" 7 | 8 | "github.com/abdullin/omni/env" 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | func modules(ms []env.Module) *Context { 13 | var c = &Context{} 14 | for _, m := range ms { 15 | r := env.NewContainer() 16 | m.Register(r) 17 | 18 | c.Items = append(c.Items, r) 19 | } 20 | return c 21 | } 22 | 23 | func (c *Context) WireHttp(router *mux.Router) { 24 | for _, x := range c.Items { 25 | for _, route := range x.Routes { 26 | api.Handle(router, route) 27 | } 28 | } 29 | } 30 | 31 | func (c *Context) WireHandlers(bus bus.Bus) { 32 | for _, x := range c.Items { 33 | for n, h := range x.Handlers { 34 | bus.AddEventHandler(n, h) 35 | } 36 | } 37 | } 38 | 39 | type Context struct { 40 | Items []*env.Container 41 | } 42 | -------------------------------------------------------------------------------- /hosting/wire.go: -------------------------------------------------------------------------------- 1 | package hosting 2 | 3 | import "github.com/abdullin/omni/env" 4 | 5 | func New(modules []env.Module) *Context { 6 | context := &Context{} 7 | for _, mod := range modules { 8 | container := env.NewContainer() 9 | mod.Register(container) 10 | context.Items = append(context.Items, container) 11 | } 12 | return context 13 | 14 | } 15 | -------------------------------------------------------------------------------- /id.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "strings" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/pborman/uuid" 13 | ) 14 | 15 | var ( 16 | EmptyId Id 17 | seq uint32 18 | node = nodeId() 19 | ) 20 | 21 | type Id [16]byte 22 | 23 | func nodeId() uint32 { 24 | n := uuid.NodeID() 25 | return binary.BigEndian.Uint32(n) 26 | } 27 | 28 | func NewId() Id { 29 | var uuid [16]byte 30 | var now = time.Now().UTC() 31 | 32 | nano := now.UnixNano() 33 | incr := atomic.AddUint32(&seq, 1) 34 | 35 | binary.BigEndian.PutUint64(uuid[0:], uint64(nano)) 36 | binary.BigEndian.PutUint32(uuid[8:], incr) 37 | binary.BigEndian.PutUint32(uuid[12:], node) 38 | 39 | return uuid 40 | } 41 | 42 | func (id Id) Bytes() []byte { 43 | return id[:] 44 | } 45 | 46 | func (id Id) Time() time.Time { 47 | bytes := id[:] 48 | nsec := binary.BigEndian.Uint64(bytes) 49 | return time.Unix(0, int64(nsec)).UTC() 50 | } 51 | 52 | func (s Id) Equals(other Id) (eq bool) { 53 | eq = bytes.Equal(s.Bytes(), other.Bytes()) 54 | return 55 | } 56 | 57 | func (id Id) IsEmpty() bool { 58 | for _, x := range id { 59 | if x != 0 { 60 | return false 61 | } 62 | } 63 | return true 64 | } 65 | 66 | func (id Id) String() string { 67 | return hex.EncodeToString(id[:]) 68 | } 69 | func ParseId(value string) (id Id, err error) { 70 | if len(value) == 0 { 71 | err = fmt.Errorf("Invalid id: value is empty") 72 | return 73 | } 74 | 75 | var b []byte 76 | orgValue := value 77 | 78 | if len(value) != 32 { 79 | value = strings.Map(func(r rune) rune { 80 | if r == '-' || r == '{' || r == '}' { 81 | return -1 82 | } 83 | return r 84 | }, value) 85 | } 86 | 87 | if b, err = hex.DecodeString(value); err != nil { 88 | err = fmt.Errorf("invalid id %v: %v", orgValue, err.Error()) 89 | return 90 | } 91 | 92 | if len(b) != 16 { 93 | err = fmt.Errorf("invalid id %v: did not convert to a 16 byte array", orgValue) 94 | return 95 | } 96 | 97 | for index, value := range b { 98 | id[index] = value 99 | } 100 | 101 | return 102 | } 103 | 104 | // JSON marshalling 105 | func (id Id) MarshalJSON() ([]byte, error) { 106 | if id.IsEmpty() { 107 | return []byte("\"\""), nil 108 | } 109 | 110 | jsonString := `"` + hex.EncodeToString(id[:]) + `"` 111 | return []byte(jsonString), nil 112 | } 113 | 114 | func (id *Id) UnmarshalJSON(data []byte) error { 115 | jsonString := string(data) 116 | valueString := strings.Trim(jsonString, "\"") 117 | 118 | value, err := ParseId(valueString) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | *id = value 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /log/setup.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/op/go-logging" 7 | ) 8 | 9 | var l = logging.MustGetLogger("setup") 10 | 11 | func Init(prefix string) { 12 | 13 | format := "%{time:15:04:05} %{level:.1s} (%{module}): %{message}" 14 | fmt := logging.MustStringFormatter(format) 15 | logging.SetFormatter(fmt) 16 | 17 | var backends []logging.Backend 18 | 19 | // Setup one stdout and one syslog backend. 20 | logBackend := logging.NewLogBackend(os.Stdout, "", 0) 21 | logBackend.Color = true 22 | backends = append(backends, logBackend) 23 | 24 | // also write to session 25 | if sys, err := logging.NewSyslogBackend(prefix); err != nil { 26 | l.Warning("syslog unavalable: %s", err) 27 | } else { 28 | backends = append(backends, sys) 29 | } 30 | 31 | logging.SetBackend(backends...) 32 | } 33 | -------------------------------------------------------------------------------- /spec/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | default: build 4 | 5 | build: 6 | go vet && go build 7 | -------------------------------------------------------------------------------- /spec/context.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "github.com/abdullin/omni/env" 4 | 5 | func NewContext(spec *env.Spec) *Context { 6 | return &Context{ 7 | pub: newPublisher(), 8 | spec: spec, 9 | } 10 | } 11 | 12 | type Context struct { 13 | pub *publisher 14 | spec *env.Spec 15 | } 16 | 17 | func (c *Context) Pub() env.Publisher { 18 | return c.pub 19 | } 20 | 21 | func (c *Context) Verify(m env.Module) *Report { 22 | return buildAndVerify(c.pub, c.spec, m) 23 | } 24 | -------------------------------------------------------------------------------- /spec/marshal.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func unmarshal(d []byte, i interface{}) { 9 | err := json.Unmarshal(d, i) 10 | if err != nil { 11 | panic(fmt.Sprintf("Failed to unmarshal '%s': %s", string(d), err)) 12 | } 13 | } 14 | func marshal(i interface{}) []byte { 15 | b, err := json.Marshal(i) 16 | if err != nil { 17 | panic("Failed to marshal: " + err.Error()) 18 | } 19 | return b 20 | } 21 | func marshalIndent(i interface{}) []byte { 22 | b, err := json.MarshalIndent(i, "", " ") 23 | if err != nil { 24 | panic("Failed to marshal: " + err.Error()) 25 | } 26 | return b 27 | } 28 | 29 | func marshalToMap(i interface{}) map[string]interface{} { 30 | item := map[string]interface{}{} 31 | unmarshal(marshal(i), &item) 32 | return item 33 | } 34 | -------------------------------------------------------------------------------- /spec/publisher.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "github.com/abdullin/omni" 4 | 5 | func newPublisher() *publisher { 6 | return &publisher{} 7 | } 8 | 9 | type publisher struct { 10 | Events []core.Event 11 | } 12 | 13 | func (p *publisher) MustPublish(es ...core.Event) { 14 | if err := p.Publish(es...); err != nil { 15 | panic(err) 16 | } 17 | } 18 | func (p *publisher) Publish(es ...core.Event) error { 19 | p.Events = append(p.Events, es...) 20 | return nil 21 | } 22 | 23 | func (p *publisher) Clear() { 24 | p.Events = nil 25 | } 26 | -------------------------------------------------------------------------------- /spec/report.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/abdullin/omni" 10 | "github.com/abdullin/omni/env" 11 | ) 12 | 13 | // Scenario result 14 | type Result struct { 15 | UseCase *env.UseCase 16 | Events []core.Event 17 | ResponseRaw *httptest.ResponseRecorder 18 | Response *env.Response 19 | 20 | Issues []string 21 | } 22 | 23 | func (r *Result) Ok() bool { 24 | return len(r.Issues) == 0 25 | } 26 | 27 | type Report struct { 28 | Insanity []string 29 | Resuls []*Result 30 | } 31 | 32 | func NewReport() *Report { 33 | return &Report{} 34 | } 35 | 36 | func (r *Report) failSanity(s string, args ...interface{}) { 37 | r.Insanity = append(r.Insanity, fmt.Sprintf(s, args...)) 38 | } 39 | 40 | func (r *Report) addInsanities(insanities []string) { 41 | r.Insanity = append(r.Insanity, insanities...) 42 | } 43 | 44 | func prettyPrintEvent(e core.Event) string { 45 | if e == nil { 46 | return fmt.Sprintf("%T", e) 47 | } 48 | if s, ok := e.(fmt.Stringer); ok { 49 | return fmt.Sprintf("%s", s.String()) 50 | } else { 51 | 52 | return fmt.Sprintf("%s %s", e.Meta().Contract, string(marshalIndent(e))) 53 | } 54 | } 55 | 56 | func (r *Report) ToTesting(t *testing.T) { 57 | 58 | fmt.Println("================== Module Use-Cases ===================\n") 59 | for _, x := range r.Insanity { 60 | t.Fail() 61 | fmt.Println("☹", x) 62 | } 63 | 64 | specFailed := false 65 | for _, r := range r.Resuls { 66 | if r.Ok() { 67 | fmt.Println("♥", r.UseCase.Name) 68 | } else { 69 | specFailed = true 70 | 71 | t.Fail() 72 | fmt.Println("✗", r.UseCase.Name) 73 | } 74 | } 75 | 76 | if specFailed { 77 | fmt.Println("\n============= Failure Details (TDD Mode) ==============\n") 78 | 79 | for _, r := range r.Resuls { 80 | if !r.Ok() { 81 | fmt.Println("✗", r.UseCase.Name, "\n") 82 | 83 | printDetail(r) 84 | 85 | fmt.Println("-------------------------------------------------------\n") 86 | } 87 | } 88 | } 89 | 90 | } 91 | 92 | func printDetail(r *Result) { 93 | 94 | if len(r.UseCase.Given) > 0 { 95 | fmt.Println("Given_events:") 96 | for i, e := range r.UseCase.Given { 97 | fmt.Printf("%v. %s\n", i+1, prettyPrintEvent(e)) 98 | } 99 | } 100 | 101 | if r.UseCase.When != nil { 102 | when := r.UseCase.When 103 | uri, err := url.Parse(when.Path) 104 | guard("url.Parse", err) 105 | fmt.Println("When_request:", when.Method, uri.Path) 106 | query := uri.Query() 107 | if len(query) > 0 { 108 | fmt.Println(" with") 109 | 110 | for k, _ := range query { 111 | fmt.Printf(" %s = '%s'\n", k, query.Get(k)) 112 | } 113 | 114 | } 115 | } 116 | 117 | if resp := r.UseCase.ThenResponse; resp != nil { 118 | fmt.Printf("Expect_HTTP: ") 119 | printResponse(resp) 120 | } 121 | if resp := r.Response; resp != nil { 122 | fmt.Printf("Actual_HTTP: ") 123 | printResponse(resp) 124 | } 125 | 126 | if es := r.UseCase.ThenEvents; es != nil { 127 | fmt.Println("Expect_Events:", len(es)) 128 | 129 | for i, e := range es { 130 | fmt.Printf("%v. %s\n", i, prettyPrintEvent(e)) 131 | } 132 | } 133 | if es := r.Events; es != nil { 134 | fmt.Println("Actual_Events:", len(es)) 135 | for i, e := range es { 136 | fmt.Printf("%v. %s\n", i+1, prettyPrintEvent(e)) 137 | } 138 | } 139 | 140 | if len(r.Issues) > 0 { 141 | fmt.Println("Issues_to_fix:") 142 | for i, x := range r.Issues { 143 | fmt.Printf("%v. %s\n", i+1, x) 144 | } 145 | } 146 | 147 | } 148 | 149 | func printResponse(resp *env.Response) { 150 | 151 | body := "" 152 | 153 | if resp.Body != nil { 154 | body = (string(marshalIndent(resp.Body))) 155 | } 156 | fmt.Println(resp.Status, body) 157 | } 158 | -------------------------------------------------------------------------------- /spec/sanity.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | func checkSanity(scenarios []*scenario) []string { 9 | out := []string{} 10 | 11 | bad := func(line string, args ...interface{}) { 12 | out = append(out, fmt.Sprintf(line, args...)) 13 | } 14 | 15 | if len(scenarios) == 0 { 16 | bad("Nothing to verify. Did you add usecases?") 17 | } 18 | 19 | for i, s := range scenarios { 20 | uc := s.UseCase 21 | name := uc.Name 22 | if uc.Name == "" { 23 | name := strconv.Itoa(i) 24 | bad("Must have a name '%s'", name) 25 | } 26 | if len(uc.Given) == 0 && uc.When == nil { 27 | bad("Must have either given or when: '%s'", name) 28 | } 29 | if (uc.When == nil) != (uc.ThenResponse == nil) { 30 | bad("When and ThenResponse must be provided both: '%s'", name) 31 | } 32 | 33 | } 34 | return out 35 | } 36 | -------------------------------------------------------------------------------- /spec/syntax_then_events.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "github.com/abdullin/omni" 4 | 5 | func Events(events ...core.Event) []core.Event { 6 | if len(events) == 0 { 7 | return []core.Event{} 8 | } 9 | return events 10 | 11 | } 12 | -------------------------------------------------------------------------------- /spec/syntax_then_response.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/abdullin/omni/env" 7 | ) 8 | 9 | // default errors are in JSON 10 | func ReturnErrorJSON(status int, error string) *env.Response { 11 | return &env.Response{ 12 | Status: status, 13 | Body: map[string]string{ 14 | "error": error, 15 | }, 16 | 17 | Headers: http.Header{ 18 | "Content-Type": []string{"application/json"}, 19 | }, 20 | } 21 | } 22 | 23 | func ReturnJSON(response interface{}) *env.Response { 24 | return &env.Response{ 25 | Status: http.StatusOK, 26 | Body: response, 27 | 28 | Headers: http.Header{ 29 | "Content-Type": []string{"application/json"}, 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /spec/syntax_when_request.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/abdullin/omni" 7 | "github.com/abdullin/omni/env" 8 | ) 9 | 10 | type Values map[string]string 11 | 12 | func GetJSON(url string, values Values) *env.Request { 13 | return &env.Request{ 14 | Method: "GET", 15 | Path: url, 16 | Headers: nil, 17 | Body: "", 18 | } 19 | } 20 | func PostJSON(url string, subj interface{}) *env.Request { 21 | return &env.Request{ 22 | Method: "POST", 23 | Path: url, 24 | Headers: http.Header{ 25 | "Content-Type": []string{"application/json"}, 26 | }, 27 | Body: subj, 28 | } 29 | } 30 | 31 | func PutJSON(url string, subj interface{}) *env.Request { 32 | return &env.Request{ 33 | Method: "PUT", 34 | Path: url, 35 | Headers: http.Header{ 36 | "Content-Type": []string{"application/json"}, 37 | }, 38 | Body: subj, 39 | } 40 | } 41 | 42 | func GivenEvents(es ...core.Event) []core.Event { 43 | if len(es) == 0 { 44 | return []core.Event{} 45 | } 46 | return es 47 | } 48 | -------------------------------------------------------------------------------- /spec/syntax_where.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "strings" 4 | 5 | type Where map[interface{}]string 6 | 7 | func (w Where) Map() map[string]string { 8 | 9 | clean := map[string]string{} 10 | 11 | for k, v := range w { 12 | key := strings.Trim(string(marshal(k)), `"`) 13 | clean[key] = v 14 | } 15 | 16 | return clean 17 | } 18 | -------------------------------------------------------------------------------- /spec/verify.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | 9 | "bytes" 10 | 11 | "github.com/abdullin/omni" 12 | "github.com/abdullin/omni/api" 13 | "github.com/abdullin/omni/env" 14 | "github.com/abdullin/seq" 15 | "github.com/gorilla/mux" 16 | ) 17 | 18 | type scenario struct { 19 | UseCase *env.UseCase 20 | } 21 | 22 | func makeScenarios(ucs []env.UseCaseFactory) []*scenario { 23 | 24 | var out = []*scenario{} 25 | 26 | for _, f := range ucs { 27 | uc := f() 28 | sc := &scenario{uc} 29 | out = append(out, sc) 30 | 31 | } 32 | return out 33 | 34 | } 35 | 36 | func buildAndVerify(pub *publisher, spec *env.Spec, mod env.Module) *Report { 37 | 38 | var report = NewReport() 39 | 40 | // TODO: sanity checks 41 | container := env.NewContainer() 42 | mod.Register(container) 43 | scenarios := makeScenarios(spec.UseCases) 44 | 45 | insanities := checkSanity(scenarios) 46 | if len(insanities) > 0 { 47 | // fail fast 48 | report.addInsanities(insanities) 49 | return report 50 | } 51 | 52 | router := mux.NewRouter() 53 | 54 | // wire routes 55 | for _, route := range container.Routes { 56 | api.Handle(router, route) 57 | } 58 | 59 | for _, s := range scenarios { 60 | 61 | // reset data 62 | pub.Clear() 63 | for _, r := range container.DataReset { 64 | r() 65 | } 66 | 67 | result := &Result{ 68 | UseCase: s.UseCase, 69 | } 70 | 71 | dispatchEvents(s.UseCase.Given, container.Handlers) 72 | 73 | issues := []seq.Issue{} 74 | 75 | if s.UseCase.When != nil { 76 | response := performRequest(s.UseCase.When, router) 77 | decodedResponse := decodeResponse(response) 78 | responseResult := verifyResponse(s.UseCase.ThenResponse, decodedResponse) 79 | 80 | issues = append(issues, responseResult.Issues...) 81 | 82 | result.ResponseRaw = response 83 | result.Response = decodedResponse 84 | } 85 | { 86 | events := pub.Events 87 | eventsResult := verifyEvents(s.UseCase.ThenEvents, events) 88 | result.Events = events 89 | issues = append(issues, eventsResult.Issues...) 90 | } 91 | 92 | result.Issues = excludeExpectedIssues(issues, s.UseCase.Where) 93 | 94 | report.Resuls = append(report.Resuls, result) 95 | } 96 | 97 | return report 98 | } 99 | 100 | func excludeExpectedIssues(issues []seq.Issue, where env.Where) []string { 101 | 102 | var m map[string]string 103 | if where != nil { 104 | m = where.Map() 105 | } 106 | cleaned := []string{} 107 | 108 | groups := map[string][]seq.Issue{} 109 | 110 | for _, issue := range issues { 111 | if excuse, ok := m[issue.ExpectedValue]; !ok { 112 | // no excuse 113 | cleaned = append(cleaned, issue.String()) 114 | } else { 115 | switch excuse { 116 | case "ignore": 117 | break 118 | default: 119 | groups[excuse] = append(groups[excuse], issue) 120 | } 121 | } 122 | } 123 | 124 | for k, issues := range groups { 125 | if !allItemsHaveSameValue(issues) { 126 | line := fmt.Sprintf("Expected '%s' fields to be equal", k) 127 | cleaned = append(cleaned, line) 128 | } 129 | 130 | } 131 | 132 | return cleaned 133 | } 134 | 135 | func allItemsHaveSameValue(issues []seq.Issue) bool { 136 | for i, issue := range issues { 137 | if i == 0 { 138 | continue 139 | } 140 | 141 | if issue.ActualValue != issues[0].ActualValue { 142 | return false 143 | } 144 | } 145 | return true 146 | 147 | } 148 | 149 | func guard(name string, err error) { 150 | if err != nil { 151 | panic(fmt.Errorf("%s: %s", name, err)) 152 | } 153 | } 154 | func dispatchEvents(given []core.Event, handlers env.EventHandlerMap) { 155 | 156 | for _, e := range given { 157 | contract := e.Meta().Contract 158 | for _, h := range handlers { 159 | for _, c := range h.Contracts() { 160 | if c == contract { 161 | h.HandleEvent(e) 162 | } 163 | } 164 | } 165 | } 166 | } 167 | 168 | func performRequest(when *env.Request, router http.Handler) *httptest.ResponseRecorder { 169 | 170 | server := httptest.NewServer(router) 171 | defer server.Close() 172 | 173 | root := server.URL 174 | 175 | var buffer *bytes.Buffer 176 | 177 | if when.Body != nil { 178 | buffer = bytes.NewBuffer(marshal(when.Body)) 179 | } 180 | 181 | request, err1 := http.NewRequest(when.Method, root+when.Path, buffer) 182 | guard("new request", err1) 183 | 184 | request.Header = when.Headers 185 | response, err2 := http.DefaultClient.Do(request) 186 | guard("response", err2) 187 | 188 | recorder := httptest.NewRecorder() 189 | 190 | if _, err := io.Copy(recorder, response.Body); err != nil { 191 | panic(err) 192 | } 193 | recorder.Code = response.StatusCode 194 | recorder.HeaderMap = response.Header 195 | 196 | return recorder 197 | } 198 | 199 | func decodeBody(response *httptest.ResponseRecorder) interface{} { 200 | if response.Body == nil { 201 | return nil 202 | } 203 | contentType := response.HeaderMap.Get("Content-Type") 204 | 205 | switch contentType { 206 | case "application/json": 207 | var body map[string]interface{} 208 | unmarshal(response.Body.Bytes(), &body) 209 | return body 210 | default: 211 | return response.Body.String() 212 | 213 | } 214 | } 215 | 216 | func decodeResponse(actual *httptest.ResponseRecorder) *env.Response { 217 | return &env.Response{ 218 | Status: actual.Code, 219 | Headers: actual.HeaderMap, 220 | Body: decodeBody(actual), 221 | } 222 | } 223 | 224 | func verifyEvents(then []core.Event, actual []core.Event) *seq.Result { 225 | prepareArray := func(es []core.Event) []map[string]interface{} { 226 | out := []map[string]interface{}{} 227 | for _, e := range es { 228 | item := marshalToMap(e) 229 | item["$contract"] = e.Meta().Contract 230 | out = append(out, item) 231 | 232 | } 233 | return out 234 | } 235 | 236 | expectedMap := seq.Map{ 237 | "Events": prepareArray(then), 238 | } 239 | actualMap := seq.Map{ 240 | "Events": prepareArray(actual), 241 | } 242 | result := seq.Test(expectedMap, actualMap) 243 | 244 | return result 245 | } 246 | 247 | func verifyResponse(then *env.Response, decoded *env.Response) *seq.Result { 248 | expected := seq.Map{ 249 | "Status": then.Status, 250 | "Headers": then.Headers, 251 | "Body": then.Body, 252 | } 253 | 254 | actual := seq.Map{ 255 | "Status": decoded.Status, 256 | "Headers": decoded.Headers, 257 | "Body": decoded.Body, 258 | } 259 | 260 | result := seq.Test(expected, actual) 261 | return result 262 | 263 | } 264 | -------------------------------------------------------------------------------- /watch-and-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | reflex -d none -R '^bin' -R 'flycheck_' -s make run 3 | --------------------------------------------------------------------------------