├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── backend ├── mongo │ ├── backend.go │ ├── backend_test.go │ ├── plugin.go │ ├── topic.go │ └── topic_test.go ├── nsq │ └── plugin.go └── redis │ ├── backend.go │ ├── backend_test.go │ ├── plugin.go │ ├── topic.go │ └── topic_test.go ├── bolt ├── backend.go ├── backend_test.go ├── boltpb │ ├── boltpb.go │ ├── message.pb.go │ ├── message.proto │ ├── topic.pb.go │ └── topic.proto ├── registry.go ├── registry_test.go ├── topic.go └── topic_test.go ├── cli ├── app │ ├── app.go │ ├── app_test.go │ ├── bolt.go │ ├── bolt_test.go │ ├── config.go │ ├── config_test.go │ ├── directories.go │ ├── errors.go │ ├── plugins.go │ ├── plugins_test.go │ ├── registry.go │ ├── registry_test.go │ ├── servers.go │ ├── servers_test.go │ ├── step.go │ └── step_test.go ├── cmd.go ├── plugin.go └── run.go ├── cmd └── lobby │ └── main.go ├── docker-compose.yml ├── errors.go ├── etcd ├── etcdpb │ ├── etcdpb.go │ ├── topic.pb.go │ └── topic.proto ├── registry.go ├── registry_test.go └── topic.proto ├── glide.lock ├── glide.yaml ├── http ├── errors.go ├── http.go ├── http_test.go └── request.go ├── io.go ├── io_test.go ├── log ├── logger.go └── logger_test.go ├── mock ├── backend.go ├── plugin.go ├── registry.go └── topic.go ├── plugin.go ├── rpc ├── backend.go ├── backend_test.go ├── errors.go ├── plugin.go ├── plugin_test.go ├── proto │ ├── proto.go │ ├── registry.pb.go │ ├── registry.proto │ ├── topic.pb.go │ └── topic.proto ├── registry.go ├── registry_test.go ├── server.go ├── server_test.go ├── topic.go └── topic_test.go ├── server.go ├── topic.go ├── validation ├── errors.go └── validation.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | *.iml 4 | 5 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 6 | *.o 7 | *.a 8 | *.so 9 | 10 | # Folders 11 | _obj 12 | _test 13 | 14 | # Architecture specific extensions/prefixes 15 | *.[568vq] 16 | [568vq].out 17 | 18 | *.cgo1.go 19 | *.cgo2.c 20 | _cgo_defun.c 21 | _cgo_gotypes.go 22 | _cgo_export.* 23 | 24 | _testmain.go 25 | 26 | *.exe 27 | *.test 28 | *.prof 29 | 30 | *.db 31 | config.yml 32 | vendor/ 33 | 34 | .DS_Store 35 | 36 | bin/ 37 | 38 | .lobby/ 39 | lobby.toml 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: go 4 | 5 | services: 6 | - docker 7 | 8 | before_install: 9 | - wget "https://github.com/Masterminds/glide/releases/download/v0.13.1/glide-v0.13.1-linux-amd64.tar.gz" 10 | - mkdir -p $HOME/bin 11 | - tar -vxz -C $HOME/bin --strip=1 -f glide-v0.13.1-linux-amd64.tar.gz 12 | - export PATH="$HOME/bin:$PATH" 13 | - docker-compose up -d 14 | 15 | install: make install 16 | 17 | go: 18 | - 1.9 19 | 20 | script: 21 | - make testrace 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Asdine El Hrychy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := lobby 2 | 3 | .PHONY: all $(NAME) deps install test testrace bench gen plugin plugins 4 | 5 | all: $(NAME) 6 | 7 | $(NAME): 8 | go install ./cmd/$@ 9 | 10 | deps: 11 | glide up 12 | 13 | install: 14 | glide install 15 | 16 | test: 17 | go test -v -cover -timeout=1m ./... 18 | 19 | testrace: 20 | go test -v -race -cover -timeout=2m ./... 21 | 22 | bench: 23 | go test -run=NONE -bench=. -benchmem ./... 24 | 25 | gen: 26 | go generate ./... 27 | 28 | plugin: 29 | mkdir -p ./bin 30 | go build -o ./bin/$(NAME)-$(PLUGIN) ./backend/$(PLUGIN) 31 | 32 | plugins: 33 | make plugin PLUGIN=mongo 34 | make plugin PLUGIN=redis 35 | make plugin PLUGIN=nsq 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lobby 2 | 3 | [![GoDoc](https://godoc.org/github.com/asdine/lobby?status.svg)](https://godoc.org/github.com/asdine/lobby) 4 | [![Build Status](https://travis-ci.org/asdine/lobby.svg)](https://travis-ci.org/asdine/lobby) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/asdine/lobby)](https://goreportcard.com/report/github.com/asdine/lobby) 6 | 7 | Lobby is an open-source pluggable platform for data delivery. 8 | 9 | ![Credits @jrmneveu](https://user-images.githubusercontent.com/2102036/35443117-29b9226e-02aa-11e8-8b03-217d650ef4af.png) 10 | 11 | ## Overview 12 | 13 | At the core, Lobby is a framework to assemble network APIs and backends. 14 | It provides several key features: 15 | 16 | - **Topics**: Applications can create topics they can use to save data using the API of their choice. A topic is bound to a particular backend, Lobby can route data to the right backend so applications can target multiple stores using different topics. 17 | - **Protocol and backend agnostic**: Data can be received using HTTP, gRPC, asynchronous consumers or by any other means and delivered to any database, message broker or even proxied to another service. Lobby provides a solid framework that links everything together. 18 | - **Plugin based architecture**: Lobby can be extended using plugins. New APIs and backends can be written using any language that supports gRPC. 19 | 20 | ## How it works 21 | 22 | ### Topic 23 | 24 | Lobby uses a concept of **Topic** to store data. Each topic is associated to a specific backend and provide an unified API to send values. 25 | 26 | ### Backend 27 | 28 | A backend is the storage unit used by Lobby. It usually represents a datastore or a message broker but can litteraly be anything that satisfies the backend interface, like an http proxy, a file or a memory store. 29 | By default, Lobby is shipped with a builtin BoltDB backend and provides MongoDB and Redis backends as plugins. 30 | 31 | ### Entrypoints 32 | 33 | Lobby can run multiple servers at the same time, each providing a different entrypoint to manipulate topics. Those entrypoints can create and manipulate all or part of Lobby's topics. 34 | By default, Lobby runs an HTTP server and a gRPC server which is the main communication system, also used to communicate with plugins. NSQ is provided as a plugin. 35 | 36 | ## Usage 37 | 38 | Running Lobby: 39 | 40 | ```sh 41 | lobby run 42 | ``` 43 | 44 | The previous command runs the gRPC server, the HTTP server and the BoltDB backend. 45 | 46 | To run Lobby with plugins: 47 | 48 | ```sh 49 | lobby run --server=nsq --backend=mongo --backend=redis 50 | ``` 51 | 52 | The previous command adds a NSQ consumer, a MongoDB and a Redis backend. 53 | 54 | Currently, Lobby contains no topics. 55 | 56 | The following command creates a topic with a Redis backend using the HTTP API: 57 | 58 | ```sh 59 | curl -X POST -d '{"name": "quotes", "backend": "redis"}' http://localhost:5657/v1/topics 60 | ``` 61 | 62 | Once the topic is created, data can be sent to it. 63 | 64 | The following command will send the following value in the `quotes` topic. 65 | 66 | ```sh 67 | curl -X POST -d 'There is no blue without yellow and without orange.' \ 68 | http://localhost:5657/v1/topics/quotes 69 | ``` 70 | -------------------------------------------------------------------------------- /backend/mongo/backend.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/asdine/lobby" 5 | "gopkg.in/mgo.v2" 6 | ) 7 | 8 | var _ lobby.Backend = new(Backend) 9 | 10 | // NewBackend returns a MongoDB backend. 11 | func NewBackend(uri string) (*Backend, error) { 12 | var err error 13 | 14 | session, err := mgo.Dial(uri) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | err = ensureIndexes(session.DB("")) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return &Backend{ 25 | session: session, 26 | }, nil 27 | } 28 | 29 | func ensureIndexes(db *mgo.Database) error { 30 | col := db.C(colMessages) 31 | 32 | index := mgo.Index{ 33 | Key: []string{"topic", "group"}, 34 | Sparse: true, 35 | } 36 | 37 | return col.EnsureIndex(index) 38 | } 39 | 40 | // Backend is a MongoDB backend. 41 | type Backend struct { 42 | session *mgo.Session 43 | } 44 | 45 | // Topic returns the topic associated with the given name. 46 | func (s *Backend) Topic(name string) (lobby.Topic, error) { 47 | return NewTopic(s.session.Copy(), name), nil 48 | } 49 | 50 | // Close MongoDB connection. 51 | func (s *Backend) Close() error { 52 | s.session.Close() 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /backend/mongo/backend_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func getBackend(t *testing.T) (*Backend, func()) { 10 | bck, err := NewBackend("mongodb://localhost:27017/test-db") 11 | require.NoError(t, err) 12 | 13 | return bck, func() { 14 | _, err := bck.session.DB("").C(colMessages).RemoveAll(nil) 15 | if err != nil { 16 | t.Error(err) 17 | } 18 | } 19 | } 20 | 21 | func TestBackend(t *testing.T) { 22 | backend, cleanup := getBackend(t) 23 | defer cleanup() 24 | 25 | topic, err := backend.Topic("a") 26 | require.NoError(t, err) 27 | require.NotNil(t, topic) 28 | require.NotNil(t, topic.(*Topic).session) 29 | 30 | err = topic.Close() 31 | require.NoError(t, err) 32 | 33 | b1, err := backend.Topic("a") 34 | require.NoError(t, err) 35 | 36 | b2, err := backend.Topic("b") 37 | require.NoError(t, err) 38 | 39 | err = b1.Close() 40 | require.NoError(t, err) 41 | 42 | err = b2.Close() 43 | require.NoError(t, err) 44 | } 45 | -------------------------------------------------------------------------------- /backend/mongo/plugin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/asdine/lobby" 5 | "github.com/asdine/lobby/cli" 6 | ) 7 | 8 | const defaultURI = "mongodb://localhost:27017/lobby" 9 | 10 | // Config of the plugin 11 | type Config struct { 12 | URI string `toml:"uri"` 13 | } 14 | 15 | func main() { 16 | var cfg Config 17 | 18 | cli.RunBackend("mongo", func() (lobby.Backend, error) { 19 | if cfg.URI == "" { 20 | cfg.URI = defaultURI 21 | } 22 | 23 | return NewBackend(cfg.URI) 24 | }, &cfg) 25 | } 26 | -------------------------------------------------------------------------------- /backend/mongo/topic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/pkg/errors" 8 | mgo "gopkg.in/mgo.v2" 9 | ) 10 | 11 | const colMessages = "messages" 12 | 13 | type message struct { 14 | ID string `bson:"_id,omitempty"` 15 | Topic string `bson:"topic"` 16 | Group string `bson:"group"` 17 | Value interface{} `bson:"value"` 18 | } 19 | 20 | var _ lobby.Topic = new(Topic) 21 | 22 | // NewTopic returns a MongoDB Topic. 23 | func NewTopic(session *mgo.Session, name string) *Topic { 24 | return &Topic{ 25 | session: session, 26 | name: name, 27 | } 28 | } 29 | 30 | // Topic is a MongoDB implementation of a topic. 31 | type Topic struct { 32 | session *mgo.Session 33 | name string 34 | } 35 | 36 | // Send a message to the topic. 37 | func (t *Topic) Send(m *lobby.Message) error { 38 | col := t.session.DB("").C(colMessages) 39 | 40 | var raw interface{} 41 | 42 | valid, err := ValidateBytes(m.Value) 43 | if err == nil { 44 | err := json.Unmarshal(valid, &raw) 45 | if err != nil { 46 | return errors.Wrap(err, "failed to unmarshal json") 47 | } 48 | } else { 49 | raw = m.Value 50 | } 51 | 52 | err = col.Insert(&message{Group: m.Group, Topic: t.name, Value: raw}) 53 | if err != nil { 54 | return errors.Wrap(err, "failed to insert of update") 55 | } 56 | 57 | return nil 58 | } 59 | 60 | // Close the topic session. 61 | func (t *Topic) Close() error { 62 | t.session.Close() 63 | return nil 64 | } 65 | 66 | // ValidateBytes checks if the data is valid json. 67 | func ValidateBytes(data []byte) ([]byte, error) { 68 | var i json.RawMessage 69 | 70 | err := json.Unmarshal(data, &i) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return i, nil 76 | } 77 | -------------------------------------------------------------------------------- /backend/mongo/topic_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/stretchr/testify/require" 9 | "gopkg.in/mgo.v2/bson" 10 | ) 11 | 12 | func TestTopicSend(t *testing.T) { 13 | backend, cleanup := getBackend(t) 14 | defer cleanup() 15 | 16 | tp, err := backend.Topic("topic") 17 | require.NoError(t, err) 18 | 19 | for i := 0; i < 5; i++ { 20 | err = tp.Send(&lobby.Message{ 21 | Group: "group", 22 | Value: []byte(fmt.Sprintf("Value%d", i)), 23 | }) 24 | require.NoError(t, err) 25 | } 26 | 27 | topic := tp.(*Topic) 28 | col := topic.session.DB("").C(colMessages) 29 | var list []message 30 | err = col.Find(bson.M{"group": "group"}).All(&list) 31 | require.NoError(t, err) 32 | require.Len(t, list, 5) 33 | require.Equal(t, []byte("Value0"), list[0].Value) 34 | err = tp.Close() 35 | require.NoError(t, err) 36 | } 37 | -------------------------------------------------------------------------------- /backend/nsq/plugin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/asdine/lobby/cli" 9 | nsq "github.com/nsqio/go-nsq" 10 | ) 11 | 12 | const ( 13 | defaultNSQAddr = "127.0.0.1:4150" 14 | ) 15 | 16 | // Config of the plugin. 17 | type Config struct { 18 | NSQAddr string 19 | } 20 | 21 | func main() { 22 | var cfg Config 23 | 24 | cli.RunBackend("nsq", func() (lobby.Backend, error) { 25 | if cfg.NSQAddr == "" { 26 | cfg.NSQAddr = defaultNSQAddr 27 | } 28 | 29 | return NewBackend(cfg.NSQAddr) 30 | }, &cfg) 31 | } 32 | 33 | var _ lobby.Backend = new(Backend) 34 | 35 | // NewBackend returns a NSQ backend. 36 | func NewBackend(addr string) (*Backend, error) { 37 | var err error 38 | 39 | config := nsq.NewConfig() 40 | p, err := nsq.NewProducer(addr, config) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | p.SetLogger(log.New(os.Stderr, "", 0), nsq.LogLevelInfo) 46 | 47 | return &Backend{ 48 | producer: p, 49 | }, nil 50 | } 51 | 52 | // Backend is a NSQ backend. 53 | type Backend struct { 54 | producer *nsq.Producer 55 | } 56 | 57 | // Topic returns the topic associated with the given name. 58 | func (s *Backend) Topic(name string) (lobby.Topic, error) { 59 | return lobby.TopicFunc(func(m *lobby.Message) error { 60 | return s.producer.Publish(name, m.Value) 61 | }), nil 62 | } 63 | 64 | // Close NSQ connection. 65 | func (s *Backend) Close() error { 66 | s.producer.Stop() 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /backend/redis/backend.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/garyburd/redigo/redis" 8 | ) 9 | 10 | var _ lobby.Backend = new(Backend) 11 | 12 | // NewBackend returns a Redis backend. 13 | func NewBackend(addr string) (*Backend, error) { 14 | pool := redis.Pool{ 15 | MaxIdle: 3, 16 | IdleTimeout: 240 * time.Second, 17 | Dial: func() (redis.Conn, error) { 18 | c, err := redis.Dial("tcp", addr) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return c, err 23 | }, 24 | TestOnBorrow: func(c redis.Conn, t time.Time) error { 25 | _, err := c.Do("PING") 26 | return err 27 | }, 28 | } 29 | 30 | conn := pool.Get() 31 | defer conn.Close() 32 | if err := conn.Err(); err != nil { 33 | return nil, err 34 | } 35 | 36 | return &Backend{ 37 | pool: &pool, 38 | }, nil 39 | } 40 | 41 | // Backend is a Redis backend. 42 | type Backend struct { 43 | pool *redis.Pool 44 | } 45 | 46 | // Topic returns the topic associated with the given name. 47 | func (s *Backend) Topic(name string) (lobby.Topic, error) { 48 | return NewTopic(s.pool.Get(), name), nil 49 | } 50 | 51 | // Close the Redis connection. 52 | func (s *Backend) Close() error { 53 | return s.pool.Close() 54 | } 55 | -------------------------------------------------------------------------------- /backend/redis/backend_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func getBackend(t *testing.T) (*Backend, func()) { 10 | bck, err := NewBackend(":6379") 11 | require.NoError(t, err) 12 | 13 | return bck, func() { 14 | conn := bck.pool.Get() 15 | defer conn.Close() 16 | _, err := conn.Do("FLUSHDB") 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | } 21 | } 22 | 23 | func TestBackend(t *testing.T) { 24 | backend, cleanup := getBackend(t) 25 | defer cleanup() 26 | 27 | topic, err := backend.Topic("a") 28 | require.NoError(t, err) 29 | require.NotNil(t, topic) 30 | require.NotNil(t, topic.(*Topic).conn) 31 | 32 | err = topic.Close() 33 | require.NoError(t, err) 34 | 35 | b1, err := backend.Topic("a") 36 | require.NoError(t, err) 37 | 38 | b2, err := backend.Topic("b") 39 | require.NoError(t, err) 40 | 41 | err = b1.Close() 42 | require.NoError(t, err) 43 | 44 | err = b2.Close() 45 | require.NoError(t, err) 46 | } 47 | -------------------------------------------------------------------------------- /backend/redis/plugin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/asdine/lobby" 5 | "github.com/asdine/lobby/cli" 6 | ) 7 | 8 | const defaultAddr = ":6379" 9 | 10 | // Config of the plugin 11 | type Config struct { 12 | Addr string 13 | } 14 | 15 | func main() { 16 | var cfg Config 17 | 18 | cli.RunBackend("redis", func() (lobby.Backend, error) { 19 | if cfg.Addr == "" { 20 | cfg.Addr = defaultAddr 21 | } 22 | 23 | return NewBackend(cfg.Addr) 24 | }, &cfg) 25 | } 26 | -------------------------------------------------------------------------------- /backend/redis/topic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/asdine/lobby" 5 | "github.com/garyburd/redigo/redis" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | var _ lobby.Topic = new(Topic) 10 | 11 | // NewTopic returns a Redis Topic. 12 | func NewTopic(conn redis.Conn, name string) *Topic { 13 | return &Topic{ 14 | conn: conn, 15 | name: name, 16 | } 17 | } 18 | 19 | // Topic is a Redis implementation of a topic. 20 | type Topic struct { 21 | conn redis.Conn 22 | name string 23 | } 24 | 25 | // Send message to the topic. 26 | func (t *Topic) Send(m *lobby.Message) error { 27 | name := t.name 28 | if m.Group != "" { 29 | name += ":" + m.Group 30 | } 31 | 32 | _, err := t.conn.Do("RPUSH", name, m.Value) 33 | return errors.Wrapf(err, "failed to send message '%s'", name) 34 | } 35 | 36 | // Close the topic connection. 37 | func (t *Topic) Close() error { 38 | return t.conn.Close() 39 | } 40 | -------------------------------------------------------------------------------- /backend/redis/topic_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/garyburd/redigo/redis" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestTopicSend(t *testing.T) { 13 | backend, cleanup := getBackend(t) 14 | defer cleanup() 15 | 16 | tp, err := backend.Topic("topic") 17 | require.NoError(t, err) 18 | 19 | for i := 0; i < 5; i++ { 20 | err = tp.Send(&lobby.Message{ 21 | Group: "group", 22 | Value: []byte(fmt.Sprintf("Value%d", i)), 23 | }) 24 | require.NoError(t, err) 25 | } 26 | 27 | topic := tp.(*Topic) 28 | list, err := redis.ByteSlices(topic.conn.Do("LRANGE", "topic:group", "0", "-1")) 29 | require.NoError(t, err) 30 | require.Len(t, list, 5) 31 | err = tp.Close() 32 | require.NoError(t, err) 33 | } 34 | -------------------------------------------------------------------------------- /bolt/backend.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/storm" 8 | "github.com/asdine/storm/codec/protobuf" 9 | "github.com/coreos/bbolt" 10 | ) 11 | 12 | var _ lobby.Backend = new(Backend) 13 | 14 | // NewBackend returns a BoltDB backend. 15 | func NewBackend(path string) (*Backend, error) { 16 | var err error 17 | 18 | db, err := storm.Open( 19 | path, 20 | storm.Codec(protobuf.Codec), 21 | storm.BoltOptions(0644, &bolt.Options{ 22 | Timeout: time.Duration(50) * time.Millisecond, 23 | }), 24 | ) 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &Backend{ 31 | DB: db, 32 | }, nil 33 | } 34 | 35 | // Backend is a BoltDB backend. 36 | type Backend struct { 37 | DB *storm.DB 38 | } 39 | 40 | // Topic returns the topic associated with the given name. 41 | func (s *Backend) Topic(name string) (lobby.Topic, error) { 42 | return NewTopic(s.DB.From(name)), nil 43 | } 44 | 45 | // Close BoltDB connection. 46 | func (s *Backend) Close() error { 47 | return s.DB.Close() 48 | } 49 | -------------------------------------------------------------------------------- /bolt/backend_test.go: -------------------------------------------------------------------------------- 1 | package bolt_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/asdine/lobby/bolt" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type errorHandler interface { 14 | Error(args ...interface{}) 15 | } 16 | 17 | func preparePath(t errorHandler, dbName string) (string, func()) { 18 | dir, err := ioutil.TempDir(os.TempDir(), "lobby") 19 | if err != nil { 20 | t.Error(err) 21 | } 22 | 23 | return filepath.Join(dir, dbName), func() { 24 | os.RemoveAll(dir) 25 | } 26 | } 27 | 28 | func TestBackend(t *testing.T) { 29 | path, cleanup := preparePath(t, "backend.db") 30 | defer cleanup() 31 | 32 | s, err := bolt.NewBackend(path) 33 | require.NoError(t, err) 34 | defer s.Close() 35 | 36 | topic, err := s.Topic("a") 37 | require.NoError(t, err) 38 | require.NotNil(t, topic) 39 | require.NotNil(t, s.DB) 40 | 41 | err = topic.Close() 42 | require.NoError(t, err) 43 | 44 | b1, err := s.Topic("a") 45 | require.NoError(t, err) 46 | 47 | b2, err := s.Topic("b") 48 | require.NoError(t, err) 49 | 50 | err = b1.Close() 51 | require.NoError(t, err) 52 | 53 | err = b2.Close() 54 | require.NoError(t, err) 55 | } 56 | -------------------------------------------------------------------------------- /bolt/boltpb/boltpb.go: -------------------------------------------------------------------------------- 1 | package boltpb 2 | 3 | //go:generate protoc --go_out=. message.proto topic.proto 4 | //go:generate protoc-go-inject-tag -input=./topic.pb.go 5 | //go:generate protoc-go-inject-tag -input=./message.pb.go 6 | -------------------------------------------------------------------------------- /bolt/boltpb/message.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. 2 | // source: message.proto 3 | // DO NOT EDIT! 4 | 5 | /* 6 | Package boltpb is a generated protocol buffer package. 7 | 8 | It is generated from these files: 9 | message.proto 10 | topic.proto 11 | 12 | It has these top-level messages: 13 | Message 14 | Topic 15 | */ 16 | package boltpb 17 | 18 | import proto "github.com/golang/protobuf/proto" 19 | import fmt "fmt" 20 | import math "math" 21 | 22 | // Reference imports to suppress errors if they are not otherwise used. 23 | var _ = proto.Marshal 24 | var _ = fmt.Errorf 25 | var _ = math.Inf 26 | 27 | // This is a compile-time assertion to ensure that this generated file 28 | // is compatible with the proto package it is being compiled against. 29 | // A compilation error at this line likely means your copy of the 30 | // proto package needs to be updated. 31 | const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package 32 | 33 | type Message struct { 34 | // @inject_tag: storm:"id,increment" 35 | Id int64 `protobuf:"varint,1,opt,name=id" json:"id,omitempty" storm:"id,increment"` 36 | // @inject_tag: storm:"index" 37 | Group string `protobuf:"bytes,2,opt,name=group" json:"group,omitempty" storm:"index"` 38 | Value []byte `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` 39 | } 40 | 41 | func (m *Message) Reset() { *m = Message{} } 42 | func (m *Message) String() string { return proto.CompactTextString(m) } 43 | func (*Message) ProtoMessage() {} 44 | func (*Message) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } 45 | 46 | func init() { 47 | proto.RegisterType((*Message)(nil), "boltpb.Message") 48 | } 49 | 50 | func init() { proto.RegisterFile("message.proto", fileDescriptor0) } 51 | 52 | var fileDescriptor0 = []byte{ 53 | // 108 bytes of a gzipped FileDescriptorProto 54 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0xcd, 0x4d, 0x2d, 0x2e, 55 | 0x4e, 0x4c, 0x4f, 0xd5, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x4b, 0xca, 0xcf, 0x29, 0x29, 56 | 0x48, 0x52, 0x72, 0xe5, 0x62, 0xf7, 0x85, 0x48, 0x08, 0xf1, 0x71, 0x31, 0x65, 0xa6, 0x48, 0x30, 57 | 0x2a, 0x30, 0x6a, 0x30, 0x07, 0x31, 0x65, 0xa6, 0x08, 0x89, 0x70, 0xb1, 0xa6, 0x17, 0xe5, 0x97, 58 | 0x16, 0x48, 0x30, 0x29, 0x30, 0x6a, 0x70, 0x06, 0x41, 0x38, 0x20, 0xd1, 0xb2, 0xc4, 0x9c, 0xd2, 59 | 0x54, 0x09, 0x66, 0x05, 0x46, 0x0d, 0x9e, 0x20, 0x08, 0x27, 0x89, 0x0d, 0x6c, 0xaa, 0x31, 0x20, 60 | 0x00, 0x00, 0xff, 0xff, 0x21, 0x0b, 0x95, 0x24, 0x66, 0x00, 0x00, 0x00, 61 | } 62 | -------------------------------------------------------------------------------- /bolt/boltpb/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package boltpb; 4 | 5 | message Message { 6 | // @inject_tag: storm:"id,increment" 7 | int64 id = 1; 8 | // @inject_tag: storm:"index" 9 | string group = 2; 10 | bytes value = 3; 11 | } 12 | -------------------------------------------------------------------------------- /bolt/boltpb/topic.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. 2 | // source: topic.proto 3 | // DO NOT EDIT! 4 | 5 | package boltpb 6 | 7 | import proto "github.com/golang/protobuf/proto" 8 | import fmt "fmt" 9 | import math "math" 10 | 11 | // Reference imports to suppress errors if they are not otherwise used. 12 | var _ = proto.Marshal 13 | var _ = fmt.Errorf 14 | var _ = math.Inf 15 | 16 | type Topic struct { 17 | // @inject_tag: storm:"id" 18 | Name string `protobuf:"bytes,1,opt,name=Name" json:"Name,omitempty" storm:"id"` 19 | Backend string `protobuf:"bytes,2,opt,name=Backend" json:"Backend,omitempty"` 20 | } 21 | 22 | func (m *Topic) Reset() { *m = Topic{} } 23 | func (m *Topic) String() string { return proto.CompactTextString(m) } 24 | func (*Topic) ProtoMessage() {} 25 | func (*Topic) Descriptor() ([]byte, []int) { return fileDescriptor1, []int{0} } 26 | 27 | func init() { 28 | proto.RegisterType((*Topic)(nil), "boltpb.Topic") 29 | } 30 | 31 | func init() { proto.RegisterFile("topic.proto", fileDescriptor1) } 32 | 33 | var fileDescriptor1 = []byte{ 34 | // 90 bytes of a gzipped FileDescriptorProto 35 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2e, 0xc9, 0x2f, 0xc8, 36 | 0x4c, 0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x4b, 0xca, 0xcf, 0x29, 0x29, 0x48, 0x52, 37 | 0x32, 0xe5, 0x62, 0x0d, 0x01, 0x09, 0x0b, 0x09, 0x71, 0xb1, 0xf8, 0x25, 0xe6, 0xa6, 0x4a, 0x30, 38 | 0x2a, 0x30, 0x6a, 0x70, 0x06, 0x81, 0xd9, 0x42, 0x12, 0x5c, 0xec, 0x4e, 0x89, 0xc9, 0xd9, 0xa9, 39 | 0x79, 0x29, 0x12, 0x4c, 0x60, 0x61, 0x18, 0x37, 0x89, 0x0d, 0x6c, 0x8a, 0x31, 0x20, 0x00, 0x00, 40 | 0xff, 0xff, 0x4f, 0x0a, 0x10, 0x0d, 0x54, 0x00, 0x00, 0x00, 41 | } 42 | -------------------------------------------------------------------------------- /bolt/boltpb/topic.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package boltpb; 4 | 5 | message Topic { 6 | // @inject_tag: storm:"id" 7 | string Name = 1; 8 | string Backend = 2; 9 | } 10 | -------------------------------------------------------------------------------- /bolt/registry.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/bolt/boltpb" 8 | "github.com/asdine/lobby/log" 9 | "github.com/asdine/storm" 10 | "github.com/asdine/storm/codec/protobuf" 11 | "github.com/coreos/bbolt" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | var _ lobby.Registry = new(Registry) 16 | 17 | // NewRegistry returns a BoltDB Registry. 18 | func NewRegistry(path string, logger *log.Logger) (*Registry, error) { 19 | var err error 20 | 21 | db, err := storm.Open( 22 | path, 23 | storm.Codec(protobuf.Codec), 24 | storm.BoltOptions(0644, &bolt.Options{ 25 | Timeout: time.Duration(50) * time.Millisecond, 26 | }), 27 | ) 28 | 29 | if err != nil { 30 | return nil, errors.Wrap(err, "Can't open database") 31 | } 32 | 33 | return &Registry{ 34 | DB: db, 35 | logger: logger, 36 | backends: make(map[string]lobby.Backend), 37 | }, nil 38 | } 39 | 40 | // Registry is a BoltDB registry. 41 | type Registry struct { 42 | DB *storm.DB 43 | logger *log.Logger 44 | backends map[string]lobby.Backend 45 | } 46 | 47 | // RegisterBackend registers a backend under the given name. 48 | func (r *Registry) RegisterBackend(name string, backend lobby.Backend) { 49 | r.backends[name] = backend 50 | r.logger.Debugf("Registered %s backend\n", name) 51 | } 52 | 53 | // Create a topic in the registry. 54 | func (r *Registry) Create(backendName, topicName string) error { 55 | if _, ok := r.backends[backendName]; !ok { 56 | return lobby.ErrBackendNotFound 57 | } 58 | 59 | tx, err := r.DB.Begin(true) 60 | if err != nil { 61 | return errors.Wrap(err, "failed to create a transaction") 62 | } 63 | defer tx.Rollback() 64 | 65 | var topic boltpb.Topic 66 | 67 | err = tx.One("Name", topicName, &topic) 68 | if err == nil { 69 | return lobby.ErrTopicAlreadyExists 70 | } 71 | 72 | if err != storm.ErrNotFound { 73 | return errors.Wrapf(err, "failed to fetch topic %s", topicName) 74 | } 75 | 76 | err = tx.Save(&boltpb.Topic{ 77 | Name: topicName, 78 | Backend: backendName, 79 | }) 80 | 81 | if err != nil { 82 | return errors.Wrapf(err, "failed to create topic %s", topicName) 83 | } 84 | 85 | err = tx.Commit() 86 | return errors.Wrap(err, "failed to commit") 87 | } 88 | 89 | // Topic returns the selected topic from the Backend. 90 | func (r *Registry) Topic(name string) (lobby.Topic, error) { 91 | var topic boltpb.Topic 92 | 93 | err := r.DB.One("Name", name, &topic) 94 | if err == storm.ErrNotFound { 95 | return nil, lobby.ErrTopicNotFound 96 | } 97 | 98 | if err != nil { 99 | return nil, errors.Wrapf(err, "failed to fetch topic %s", name) 100 | } 101 | 102 | backend, ok := r.backends[topic.Backend] 103 | if !ok { 104 | return nil, lobby.ErrTopicNotFound 105 | } 106 | 107 | return backend.Topic(name) 108 | } 109 | 110 | // Close BoltDB connection and registered backends. 111 | func (r *Registry) Close() error { 112 | for name, backend := range r.backends { 113 | err := backend.Close() 114 | if err != nil { 115 | return errors.Wrapf(err, "failed to close backend %s", name) 116 | } 117 | 118 | r.logger.Debugf("Stopped %s backend\n", name) 119 | } 120 | 121 | err := r.DB.Close() 122 | 123 | return errors.Wrap(err, "failed to close registry") 124 | } 125 | -------------------------------------------------------------------------------- /bolt/registry_test.go: -------------------------------------------------------------------------------- 1 | package bolt_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/asdine/lobby/bolt" 9 | "github.com/asdine/lobby/log" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestRegistry(t *testing.T) { 14 | pathStore, cleanupStore := preparePath(t, "backend.db") 15 | defer cleanupStore() 16 | 17 | s, err := bolt.NewBackend(pathStore) 18 | require.NoError(t, err) 19 | defer s.Close() 20 | 21 | t.Run("create", func(t *testing.T) { 22 | pathReg, cleanupReg := preparePath(t, "reg.db") 23 | defer cleanupReg() 24 | r, err := bolt.NewRegistry(pathReg, log.New(log.Output(ioutil.Discard))) 25 | require.NoError(t, err) 26 | defer r.Close() 27 | 28 | err = r.Create("bolt1", "a") 29 | require.Equal(t, lobby.ErrBackendNotFound, err) 30 | 31 | r.RegisterBackend("bolt1", s) 32 | r.RegisterBackend("bolt2", s) 33 | 34 | err = r.Create("bolt1", "a") 35 | require.NoError(t, err) 36 | 37 | err = r.Create("bolt1", "a") 38 | require.Equal(t, lobby.ErrTopicAlreadyExists, err) 39 | 40 | err = r.Create("bolt1", "b") 41 | require.NoError(t, err) 42 | 43 | err = r.Create("bolt2", "a") 44 | require.Equal(t, lobby.ErrTopicAlreadyExists, err) 45 | }) 46 | 47 | t.Run("topic", func(t *testing.T) { 48 | pathReg, cleanupReg := preparePath(t, "reg.db") 49 | defer cleanupReg() 50 | r, err := bolt.NewRegistry(pathReg, log.New(log.Output(ioutil.Discard))) 51 | require.NoError(t, err) 52 | defer r.Close() 53 | 54 | r.RegisterBackend("bolt1", s) 55 | r.RegisterBackend("bolt2", s) 56 | 57 | b, err := r.Topic("") 58 | require.Equal(t, lobby.ErrTopicNotFound, err) 59 | 60 | b, err = r.Topic("a") 61 | require.Equal(t, lobby.ErrTopicNotFound, err) 62 | 63 | err = r.Create("bolt1", "a") 64 | require.NoError(t, err) 65 | 66 | b, err = r.Topic("a") 67 | require.NoError(t, err) 68 | require.NotNil(t, b) 69 | 70 | err = r.Create("bolt2", "b") 71 | require.NoError(t, err) 72 | 73 | b, err = r.Topic("b") 74 | require.NoError(t, err) 75 | require.NotNil(t, b) 76 | 77 | err = r.Create("bolt2", "a") 78 | require.Equal(t, lobby.ErrTopicAlreadyExists, err) 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /bolt/topic.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "github.com/asdine/lobby" 5 | "github.com/asdine/lobby/bolt/boltpb" 6 | "github.com/asdine/storm" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | var _ lobby.Topic = new(Topic) 11 | 12 | // NewTopic returns a Topic 13 | func NewTopic(node storm.Node) *Topic { 14 | return &Topic{ 15 | node: node, 16 | } 17 | } 18 | 19 | // Topic is a BoltDB implementation of a topic. 20 | type Topic struct { 21 | node storm.Node 22 | } 23 | 24 | // Send a message to the topic. 25 | func (t *Topic) Send(message *lobby.Message) error { 26 | tx, err := t.node.Begin(true) 27 | if err != nil { 28 | return errors.Wrap(err, "failed to create bolt transaction") 29 | } 30 | defer tx.Rollback() 31 | 32 | err = tx.Save(&boltpb.Message{ 33 | Group: message.Group, 34 | Value: message.Value, 35 | }) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | err = tx.Commit() 41 | if err != nil { 42 | return errors.Wrap(err, "failed to commit bolt transaction") 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // Close the topic session. 49 | func (t *Topic) Close() error { 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /bolt/topic_test.go: -------------------------------------------------------------------------------- 1 | package bolt_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/bolt" 8 | "github.com/asdine/lobby/bolt/boltpb" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestTopicSend(t *testing.T) { 13 | path, cleanup := preparePath(t, "store.db") 14 | defer cleanup() 15 | 16 | bk, err := bolt.NewBackend(path) 17 | require.NoError(t, err) 18 | 19 | tp, err := bk.Topic("1a") 20 | require.NoError(t, err) 21 | 22 | err = tp.Send(&lobby.Message{ 23 | Group: "2a", 24 | Value: []byte("Value"), 25 | }) 26 | require.NoError(t, err) 27 | 28 | var m []boltpb.Message 29 | err = bk.DB.From("1a").Find("Group", "2a", &m) 30 | require.NoError(t, err) 31 | require.Len(t, m, 1) 32 | 33 | err = tp.Send(&lobby.Message{ 34 | Group: "2a", 35 | Value: []byte("New Value"), 36 | }) 37 | require.NoError(t, err) 38 | 39 | err = bk.DB.From("1a").Find("Group", "2a", &m) 40 | require.NoError(t, err) 41 | require.Len(t, m, 2) 42 | 43 | err = tp.Close() 44 | require.NoError(t, err) 45 | } 46 | -------------------------------------------------------------------------------- /cli/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "sync" 8 | 9 | "github.com/asdine/lobby" 10 | "github.com/asdine/lobby/log" 11 | ) 12 | 13 | // App is the main application. It bootstraps all the components 14 | // and can be gracefully shutdown. 15 | type App struct { 16 | Config Config 17 | ConfigPath string 18 | Logger *log.Logger 19 | 20 | wg sync.WaitGroup 21 | errc chan error 22 | out io.Writer 23 | registry lobby.Registry 24 | steps steps 25 | } 26 | 27 | // Run all the app components. Can be gracefully shutdown using the provided context. 28 | func (a *App) Run(ctx context.Context) error { 29 | var errs Errors 30 | a.errc = make(chan error) 31 | if a.out == nil { 32 | a.out = os.Stderr 33 | } 34 | 35 | a.Logger = log.New( 36 | log.Prefix("lobby:"), 37 | log.Output(a.out), 38 | log.Debug(a.Config.Debug), 39 | ) 40 | 41 | a.logLobbyInfos() 42 | 43 | if a.steps == nil { 44 | a.steps = []step{ 45 | directoriesStep(), 46 | new(registryStep), 47 | boltBackendStep(), 48 | newBackendPluginsStep(), 49 | newGRPCUnixSocketStep(a), 50 | newGRPCPortStep(a), 51 | newHTTPStep(a), 52 | } 53 | } 54 | 55 | err := a.steps.setup(ctx, a) 56 | if err != nil && err != context.Canceled { 57 | a.Logger.Println(err) 58 | errs = append(errs, err) 59 | } 60 | 61 | if err == nil { 62 | // block until either an error or a cancel happens 63 | select { 64 | case <-ctx.Done(): 65 | if err := ctx.Err(); err != context.Canceled { 66 | errs = append(errs, err) 67 | } 68 | case err := <-a.errc: 69 | errs = append(errs, err) 70 | } 71 | } 72 | 73 | errsC := make(chan Errors) 74 | defer close(errsC) 75 | 76 | // get errors from any goroutine 77 | go func() { 78 | var errs Errors 79 | for err := range a.errc { 80 | errs = append(errs, err) 81 | } 82 | errsC <- errs 83 | }() 84 | 85 | closeErrs := a.steps.teardown(ctx, a) 86 | if len(closeErrs) != 0 { 87 | errs = append(errs, closeErrs...) 88 | } 89 | 90 | a.wg.Wait() 91 | close(a.errc) 92 | errs = append(errs, <-errsC...) 93 | 94 | if len(errs) != 0 { 95 | return errs 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (a *App) logLobbyInfos() { 102 | a.Logger.Println("lobby Version:", lobby.Version) 103 | a.Logger.Debug("Debug mode enabled") 104 | } 105 | -------------------------------------------------------------------------------- /cli/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/ioutil" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type mockStep struct { 14 | setupFn func(ctx context.Context, app *App) error 15 | teardownFn func(ctx context.Context, app *App) error 16 | } 17 | 18 | func (s *mockStep) setup(ctx context.Context, app *App) error { 19 | if s.setupFn != nil { 20 | return s.setupFn(ctx, app) 21 | } 22 | 23 | return nil 24 | } 25 | 26 | func (s *mockStep) teardown(ctx context.Context, app *App) error { 27 | if s.teardownFn != nil { 28 | return s.teardownFn(ctx, app) 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func TestApp(t *testing.T) { 35 | t.Run("SetupError", func(t *testing.T) { 36 | var app App 37 | app.out = ioutil.Discard 38 | 39 | m := mockStep{ 40 | setupFn: func(ctx context.Context, app *App) error { 41 | return errors.New("setup error") 42 | }, 43 | teardownFn: func(ctx context.Context, app *App) error { 44 | return errors.New("teardown error") 45 | }, 46 | } 47 | 48 | app.steps = []step{&m} 49 | 50 | err := app.Run(context.Background()) 51 | errs := err.(Errors) 52 | assert.Len(t, errs, 2) 53 | }) 54 | 55 | t.Run("Goroutine error", func(t *testing.T) { 56 | var app App 57 | app.out = ioutil.Discard 58 | 59 | m := mockStep{ 60 | setupFn: func(ctx context.Context, app *App) error { 61 | app.wg.Add(1) 62 | go func() { 63 | defer app.wg.Done() 64 | 65 | app.errc <- errors.New("goroutine error") 66 | }() 67 | return nil 68 | }, 69 | teardownFn: func(ctx context.Context, app *App) error { 70 | return nil 71 | }, 72 | } 73 | 74 | app.steps = []step{&m} 75 | 76 | err := app.Run(context.Background()) 77 | errs := err.(Errors) 78 | require.Len(t, errs, 1) 79 | require.EqualError(t, errs[0], "goroutine error") 80 | }) 81 | 82 | t.Run("Cancel", func(t *testing.T) { 83 | ctx, cancel := context.WithCancel(context.Background()) 84 | var app App 85 | app.out = ioutil.Discard 86 | quit := make(chan struct{}) 87 | 88 | m := mockStep{ 89 | setupFn: func(ctx context.Context, app *App) error { 90 | app.wg.Add(1) 91 | go func() { 92 | defer app.wg.Done() 93 | 94 | <-quit 95 | }() 96 | 97 | cancel() 98 | return nil 99 | }, 100 | teardownFn: func(ctx context.Context, app *App) error { 101 | quit <- struct{}{} 102 | 103 | return nil 104 | }, 105 | } 106 | 107 | app.steps = []step{&m} 108 | 109 | err := app.Run(ctx) 110 | require.NoError(t, err) 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /cli/app/bolt.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "path" 6 | 7 | "github.com/asdine/lobby/bolt" 8 | ) 9 | 10 | func boltBackendStep() step { 11 | return setupFunc(func(ctx context.Context, app *App) error { 12 | if len(app.Config.Plugins.Backends) > 0 && !app.Config.Bolt.Backend { 13 | return nil 14 | } 15 | 16 | dataPath := path.Join(app.Config.Paths.DataDir, "db") 17 | err := createDir(dataPath) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | boltPath := path.Join(dataPath, "bolt") 23 | err = createDir(boltPath) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | backendPath := path.Join(boltPath, "backend.db") 29 | 30 | // Creating default backend. 31 | bck, err := bolt.NewBackend(backendPath) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | app.registry.RegisterBackend("bolt", bck) 37 | return nil 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /cli/app/bolt_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/asdine/lobby/mock" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestBoltBackendStep(t *testing.T) { 12 | test := func(app *App) { 13 | reg := new(mock.Registry) 14 | app.registry = reg 15 | step := boltBackendStep() 16 | err := step.setup(context.Background(), app) 17 | require.NoError(t, err) 18 | bck, ok := reg.Backends["bolt"] 19 | require.True(t, ok) 20 | err = bck.Close() 21 | require.NoError(t, err) 22 | } 23 | 24 | t.Run("AsDefaultBackend", func(t *testing.T) { 25 | app, cleanup := appHelper(t) 26 | defer cleanup() 27 | 28 | test(app) 29 | }) 30 | 31 | t.Run("ExplicitBackend", func(t *testing.T) { 32 | app, cleanup := appHelper(t) 33 | defer cleanup() 34 | 35 | app.Config.Plugins.Backends = []string{"a", "b"} 36 | app.Config.Bolt.Backend = true 37 | test(app) 38 | }) 39 | 40 | t.Run("WithBackends", func(t *testing.T) { 41 | app, cleanup := appHelper(t) 42 | defer cleanup() 43 | 44 | reg := new(mock.Registry) 45 | app.registry = reg 46 | app.Config.Plugins.Backends = []string{"a", "b"} 47 | step := boltBackendStep() 48 | err := step.setup(context.Background(), app) 49 | require.NoError(t, err) 50 | _, ok := reg.Backends["bolt"] 51 | require.False(t, ok) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /cli/app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/BurntSushi/toml" 9 | "github.com/coreos/etcd/clientv3" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // Config of the application. 14 | type Config struct { 15 | Debug bool 16 | Registry string 17 | HTTP struct { 18 | Port int 19 | } 20 | Grpc struct { 21 | Port int 22 | } 23 | Bolt struct { 24 | Backend bool 25 | } 26 | Etcd clientv3.Config 27 | Paths Paths 28 | Plugins Plugins 29 | } 30 | 31 | // Plugins contains the list of backend and server plugins. 32 | type Plugins struct { 33 | Backends []string 34 | Config map[string]toml.Primitive 35 | } 36 | 37 | // Paths contains directory paths needed by the app. 38 | type Paths struct { 39 | DataDir string `toml:"data-dir"` 40 | PluginDir string `toml:"plugin-dir"` 41 | SocketDir string `toml:"socket-dir"` 42 | } 43 | 44 | // Create the DataDir and SocketDir if they don't exist. 45 | func (p *Paths) Create() error { 46 | if p.DataDir == "" { 47 | return errors.New("unspecified data directory") 48 | } 49 | 50 | if p.SocketDir == "" { 51 | p.SocketDir = path.Join(p.DataDir, "sockets") 52 | } 53 | 54 | paths := []string{ 55 | p.DataDir, 56 | p.SocketDir, 57 | } 58 | 59 | for _, path := range paths { 60 | err := createDir(path) 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func createDir(path string) error { 70 | fi, err := os.Stat(path) 71 | if err != nil { 72 | err = os.Mkdir(path, 0755) 73 | if err != nil { 74 | return errors.Wrapf(err, "Can't create directory %s", path) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | if !fi.Mode().IsDir() { 81 | return fmt.Errorf("'%s' is not a valid directory", path) 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /cli/app/config_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestPaths(t *testing.T) { 13 | t.Run("Empty Config dir", func(t *testing.T) { 14 | var p Paths 15 | err := p.Create() 16 | require.Error(t, err) 17 | }) 18 | 19 | t.Run("Bad dir", func(t *testing.T) { 20 | p := Paths{ 21 | DataDir: "/some path", 22 | SocketDir: "/some path", 23 | } 24 | err := p.Create() 25 | require.Error(t, err) 26 | }) 27 | 28 | t.Run("Exist As File", func(t *testing.T) { 29 | name, err := ioutil.TempDir("", "lobby") 30 | require.NoError(t, err) 31 | defer os.RemoveAll(name) 32 | 33 | f, err := os.Create(path.Join(name, "config")) 34 | require.NoError(t, err) 35 | err = f.Close() 36 | require.NoError(t, err) 37 | 38 | p := Paths{ 39 | DataDir: path.Join(name, "config"), 40 | SocketDir: path.Join(name, "config", "sockets"), 41 | } 42 | err = p.Create() 43 | require.Error(t, err) 44 | }) 45 | 46 | okTest := func(t *testing.T, fn func(string) *Paths) { 47 | name, err := ioutil.TempDir("", "lobby") 48 | require.NoError(t, err) 49 | defer os.RemoveAll(name) 50 | 51 | p := fn(name) 52 | 53 | err = p.Create() 54 | require.NoError(t, err) 55 | 56 | _, err = os.Stat(p.DataDir) 57 | require.NoError(t, err) 58 | 59 | _, err = os.Stat(p.SocketDir) 60 | require.NoError(t, err) 61 | 62 | err = p.Create() 63 | require.NoError(t, err) 64 | } 65 | 66 | t.Run("OK", func(t *testing.T) { 67 | okTest(t, func(name string) *Paths { 68 | return &Paths{ 69 | DataDir: path.Join(name, "config"), 70 | SocketDir: path.Join(name, "config", "sockets"), 71 | } 72 | }) 73 | }) 74 | 75 | t.Run("No socket dir", func(t *testing.T) { 76 | okTest(t, func(name string) *Paths { 77 | return &Paths{ 78 | DataDir: path.Join(name, "config"), 79 | } 80 | }) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /cli/app/directories.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func directoriesStep() step { 8 | return setupFunc(func(ctx context.Context, app *App) error { 9 | return app.Config.Paths.Create() 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /cli/app/errors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | // Errors contains a list of errors stored during the lifecycle of the App. 9 | type Errors []error 10 | 11 | func (e Errors) Error() string { 12 | var buf bytes.Buffer 13 | 14 | for i, err := range e { 15 | if i > 0 { 16 | fmt.Fprintf(&buf, "\n") 17 | } 18 | fmt.Fprintf(&buf, "Err: %s", err.Error()) 19 | } 20 | 21 | return buf.String() 22 | } 23 | -------------------------------------------------------------------------------- /cli/app/plugins.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path" 7 | "time" 8 | 9 | "github.com/asdine/lobby" 10 | "github.com/asdine/lobby/rpc" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | func newBackendPluginsStep() *backendPluginsStep { 15 | return &backendPluginsStep{ 16 | pluginLoader: rpc.LoadBackendPlugin, 17 | } 18 | } 19 | 20 | type backendPluginsStep struct { 21 | pluginLoader func(context.Context, string, string, string, string) (lobby.Backend, lobby.Plugin, error) 22 | plugins []lobby.Plugin 23 | } 24 | 25 | func (s *backendPluginsStep) setup(ctx context.Context, app *App) error { 26 | for _, name := range app.Config.Plugins.Backends { 27 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 28 | defer cancel() 29 | 30 | bck, plg, err := s.pluginLoader( 31 | ctx, 32 | name, 33 | path.Join(app.Config.Paths.PluginDir, fmt.Sprintf("lobby-%s", name)), 34 | app.Config.Paths.DataDir, 35 | app.ConfigPath, 36 | ) 37 | if err != nil { 38 | return errors.Wrapf(err, "failed to run backend '%s'", name) 39 | } 40 | 41 | app.Logger.Debugf("Started %s plugin \n", name) 42 | app.registry.RegisterBackend(name, bck) 43 | s.plugins = append(s.plugins, plg) 44 | 45 | app.wg.Add(1) 46 | go func(p lobby.Plugin) { 47 | defer app.wg.Done() 48 | 49 | err := p.Wait() 50 | if err != nil { 51 | app.Logger.Println(err) 52 | app.errc <- err 53 | } 54 | }(plg) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (s *backendPluginsStep) teardown(ctx context.Context, app *App) error { 61 | for _, p := range s.plugins { 62 | err := p.Close() 63 | if err != nil { 64 | app.Logger.Printf("Error while closing plugin %s: %s\n", p.Name(), err) 65 | app.errc <- err 66 | } 67 | 68 | err = p.Wait() 69 | if err != nil { 70 | app.Logger.Printf("Error while waiting for plugin %s to close: %s\n", p.Name(), err) 71 | app.errc <- err 72 | } 73 | 74 | app.Logger.Debugf("Stopped %s plugin\n", p.Name()) 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /cli/app/plugins_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/asdine/lobby" 10 | "github.com/asdine/lobby/mock" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestBackendPluginsSteps(t *testing.T) { 16 | t.Run("ErrorsDuringSetup", func(t *testing.T) { 17 | app, cleanup := appHelper(t) 18 | defer cleanup() 19 | 20 | app.Config.Paths.DataDir = "dataDir" 21 | app.Config.Paths.PluginDir = "pluginDir" 22 | app.Config.Plugins.Backends = make([]string, 5) 23 | var m mock.Registry 24 | app.registry = &m 25 | 26 | for i := 0; i < 5; i++ { 27 | app.Config.Plugins.Backends[i] = fmt.Sprintf("plugin%d", i) 28 | } 29 | 30 | s := newBackendPluginsStep() 31 | var i int 32 | s.pluginLoader = func(ctx context.Context, name, cmdPath, dataDir, configFile string) (lobby.Backend, lobby.Plugin, error) { 33 | i++ 34 | if i == 3 { 35 | return nil, nil, errors.New("unexpected error") 36 | } 37 | 38 | return new(mock.Backend), new(mock.Plugin), nil 39 | } 40 | 41 | err := s.setup(context.Background(), app) 42 | require.Error(t, err) 43 | require.Len(t, s.plugins, 2) 44 | require.Len(t, m.Backends, 2) 45 | 46 | err = s.teardown(context.Background(), app) 47 | require.NoError(t, err) 48 | for _, p := range s.plugins { 49 | require.Equal(t, 1, p.(*mock.Plugin).CloseInvoked) 50 | } 51 | }) 52 | 53 | t.Run("ErrorsDuringTeardown", func(t *testing.T) { 54 | app, cleanup := appHelper(t) 55 | defer cleanup() 56 | 57 | app.Config.Paths.DataDir = "dataDir" 58 | app.Config.Paths.PluginDir = "pluginDir" 59 | app.Config.Plugins.Backends = make([]string, 5) 60 | var m mock.Registry 61 | app.registry = &m 62 | 63 | for i := 0; i < 5; i++ { 64 | app.Config.Plugins.Backends[i] = fmt.Sprintf("plugin%d", i) 65 | } 66 | 67 | s := newBackendPluginsStep() 68 | s.pluginLoader = func(ctx context.Context, name, cmdPath, dataDir, configFile string) (lobby.Backend, lobby.Plugin, error) { 69 | return new(mock.Backend), new(mock.Plugin), nil 70 | } 71 | 72 | err := s.setup(context.Background(), app) 73 | require.NoError(t, err) 74 | require.Len(t, s.plugins, 5) 75 | require.Len(t, m.Backends, 5) 76 | 77 | s.plugins[3].(*mock.Plugin).CloseFn = func() error { 78 | return errors.New("unexpected error") 79 | } 80 | 81 | c := make(chan struct{}) 82 | 83 | go func() { 84 | err = s.teardown(context.Background(), app) 85 | assert.NoError(t, err) 86 | close(c) 87 | }() 88 | 89 | require.EqualError(t, <-app.errc, "unexpected error") 90 | <-c 91 | for i, p := range s.plugins { 92 | if i != 3 { 93 | require.Equal(t, 1, p.(*mock.Plugin).CloseInvoked) 94 | } 95 | } 96 | }) 97 | 98 | t.Run("OK", func(t *testing.T) { 99 | app, cleanup := appHelper(t) 100 | defer cleanup() 101 | 102 | app.Config.Paths.DataDir = "dataDir" 103 | app.Config.Paths.PluginDir = "pluginDir" 104 | app.Config.Plugins.Backends = make([]string, 5) 105 | var m mock.Registry 106 | app.registry = &m 107 | 108 | for i := 0; i < 5; i++ { 109 | app.Config.Plugins.Backends[i] = fmt.Sprintf("plugin%d", i) 110 | } 111 | 112 | s := newBackendPluginsStep() 113 | var i int 114 | s.pluginLoader = func(ctx context.Context, name, cmdPath, dataDir, configFile string) (lobby.Backend, lobby.Plugin, error) { 115 | require.Equal(t, fmt.Sprintf("plugin%d", i), name) 116 | require.Equal(t, fmt.Sprintf("pluginDir/lobby-plugin%d", i), cmdPath) 117 | require.Equal(t, "dataDir", dataDir) 118 | i++ 119 | return new(mock.Backend), new(mock.Plugin), nil 120 | } 121 | 122 | err := s.setup(context.Background(), app) 123 | require.NoError(t, err) 124 | require.Len(t, s.plugins, 5) 125 | require.Len(t, m.Backends, 5) 126 | 127 | err = s.teardown(context.Background(), app) 128 | require.NoError(t, err) 129 | for _, p := range s.plugins { 130 | require.Equal(t, 1, p.(*mock.Plugin).CloseInvoked) 131 | } 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /cli/app/registry.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "path" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/asdine/lobby/bolt" 9 | "github.com/asdine/lobby/etcd" 10 | "github.com/asdine/lobby/log" 11 | "github.com/coreos/etcd/clientv3" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type registryStep int 16 | 17 | func (registryStep) setup(ctx context.Context, app *App) error { 18 | var reg lobby.Registry 19 | var err error 20 | switch app.Config.Registry { 21 | case "": 22 | fallthrough 23 | case "bolt": 24 | app.Logger.Debug("Using bolt registry") 25 | reg, err = boltRegistry(ctx, app) 26 | case "etcd": 27 | app.Logger.Debug("Using etcd registry") 28 | reg, err = etcdRegistry(ctx, app) 29 | default: 30 | err = errors.New("unknown registry") 31 | } 32 | if err != nil { 33 | return err 34 | } 35 | 36 | app.registry = reg 37 | return nil 38 | } 39 | 40 | func boltRegistry(ctx context.Context, app *App) (lobby.Registry, error) { 41 | dataPath := path.Join(app.Config.Paths.DataDir, "db") 42 | err := createDir(dataPath) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | boltPath := path.Join(dataPath, "bolt") 48 | err = createDir(boltPath) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | registryPath := path.Join(boltPath, "registry.db") 54 | 55 | return bolt.NewRegistry(registryPath, log.New(log.Prefix("bolt registry:"), log.Debug(app.Config.Debug))) 56 | } 57 | 58 | func etcdRegistry(ctx context.Context, app *App) (lobby.Registry, error) { 59 | client, err := clientv3.New(app.Config.Etcd) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return etcd.NewRegistry( 65 | client, 66 | log.New(log.Prefix("etcd registry:"), log.Debug(app.Config.Debug)), 67 | "lobby", 68 | ) 69 | } 70 | 71 | func (registryStep) teardown(ctx context.Context, app *App) error { 72 | if app.registry != nil { 73 | app.Logger.Debug("Closing registry") 74 | err := app.registry.Close() 75 | app.registry = nil 76 | return err 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /cli/app/registry_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestRegistryStep(t *testing.T) { 11 | app, cleanup := appHelper(t) 12 | defer cleanup() 13 | 14 | var r registryStep 15 | err := r.setup(context.Background(), app) 16 | require.NoError(t, err) 17 | require.NotNil(t, app.registry) 18 | 19 | err = r.teardown(context.Background(), app) 20 | require.NoError(t, err) 21 | require.Nil(t, app.registry) 22 | } 23 | -------------------------------------------------------------------------------- /cli/app/servers.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "path" 8 | 9 | "github.com/asdine/lobby" 10 | "github.com/asdine/lobby/http" 11 | "github.com/asdine/lobby/log" 12 | "github.com/asdine/lobby/rpc" 13 | ) 14 | 15 | type serverStep struct { 16 | logger *log.Logger 17 | srv lobby.Server 18 | } 19 | 20 | func (s *serverStep) runServer(srv lobby.Server, l net.Listener, app *App) error { 21 | c := make(chan struct{}) 22 | 23 | app.wg.Add(1) 24 | go func() { 25 | defer app.wg.Done() 26 | 27 | s.srv = srv 28 | s.logger.Printf("Listening for requests on %s.\n", l.Addr().String()) 29 | close(c) 30 | err := srv.Serve(l) 31 | if err != nil { 32 | s.logger.Println(err) 33 | app.errc <- err 34 | } 35 | }() 36 | 37 | <-c 38 | return nil 39 | } 40 | 41 | func (s *serverStep) teardown(ctx context.Context, app *App) error { 42 | s.logger.Debugf("Shutting down") 43 | if s.srv != nil { 44 | err := s.srv.Stop() 45 | s.srv = nil 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func newGRPCUnixSocketStep(app *App) *gRPCUnixSocketStep { 53 | return &gRPCUnixSocketStep{ 54 | serverStep: &serverStep{ 55 | logger: log.New( 56 | log.Prefix("gRPC server:"), 57 | log.Output(app.out), 58 | log.Debug(app.Config.Debug), 59 | ), 60 | }, 61 | } 62 | } 63 | 64 | type gRPCUnixSocketStep struct { 65 | *serverStep 66 | } 67 | 68 | func (g *gRPCUnixSocketStep) setup(ctx context.Context, app *App) error { 69 | l, err := net.Listen("unix", path.Join(app.Config.Paths.SocketDir, "lobby.sock")) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | srv := rpc.NewServer( 75 | g.serverStep.logger, 76 | rpc.WithTopicService(app.registry), 77 | rpc.WithRegistryService(app.registry), 78 | ) 79 | return g.runServer(srv, l, app) 80 | } 81 | 82 | func newGRPCPortStep(app *App) *gRPCPortStep { 83 | return &gRPCPortStep{ 84 | serverStep: &serverStep{ 85 | logger: log.New( 86 | log.Prefix("gRPC server:"), 87 | log.Output(app.out), 88 | log.Debug(app.Config.Debug), 89 | ), 90 | }, 91 | } 92 | } 93 | 94 | type gRPCPortStep struct { 95 | *serverStep 96 | } 97 | 98 | func (g *gRPCPortStep) setup(ctx context.Context, app *App) error { 99 | l, err := net.Listen("tcp", fmt.Sprintf(":%d", app.Config.Grpc.Port)) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | srv := rpc.NewServer( 105 | g.serverStep.logger, 106 | rpc.WithTopicService(app.registry), 107 | rpc.WithRegistryService(app.registry), 108 | ) 109 | return g.runServer(srv, l, app) 110 | } 111 | 112 | func newHTTPStep(app *App) *httpStep { 113 | return &httpStep{ 114 | serverStep: &serverStep{ 115 | logger: log.New( 116 | log.Prefix("http server:"), 117 | log.Output(app.out), 118 | log.Debug(app.Config.Debug), 119 | ), 120 | }, 121 | } 122 | } 123 | 124 | type httpStep struct { 125 | *serverStep 126 | } 127 | 128 | func (h *httpStep) setup(ctx context.Context, app *App) error { 129 | l, err := net.Listen("tcp", fmt.Sprintf(":%d", app.Config.HTTP.Port)) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | srv := http.NewServer( 135 | http.NewHandler(app.registry, h.logger), 136 | ) 137 | return h.runServer(srv, l, app) 138 | } 139 | -------------------------------------------------------------------------------- /cli/app/servers_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestServersSteps(t *testing.T) { 12 | app, cleanup := appHelper(t) 13 | defer cleanup() 14 | 15 | testCases := []step{ 16 | newHTTPStep(app), 17 | newGRPCUnixSocketStep(app), 18 | newGRPCPortStep(app), 19 | } 20 | 21 | for _, s := range testCases { 22 | err := s.setup(context.Background(), app) 23 | require.NoError(t, err) 24 | } 25 | 26 | // bug when calling stop right after serve on http. 27 | time.Sleep(10 * time.Millisecond) 28 | 29 | for _, s := range testCases { 30 | err := s.teardown(context.Background(), app) 31 | require.NoError(t, err) 32 | } 33 | 34 | app.wg.Wait() 35 | } 36 | -------------------------------------------------------------------------------- /cli/app/step.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type step interface { 8 | setup(context.Context, *App) error 9 | teardown(context.Context, *App) error 10 | } 11 | 12 | type steps []step 13 | 14 | func (s steps) setup(ctx context.Context, app *App) error { 15 | for _, step := range s { 16 | select { 17 | case <-ctx.Done(): 18 | return ctx.Err() 19 | default: 20 | err := step.setup(ctx, app) 21 | if err != nil { 22 | return err 23 | } 24 | } 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (s steps) teardown(ctx context.Context, app *App) []error { 31 | var errs []error 32 | 33 | for i := len(s) - 1; i >= 0; i-- { 34 | err := s[i].teardown(ctx, app) 35 | if err != nil { 36 | errs = append(errs, err) 37 | } 38 | } 39 | 40 | return errs 41 | } 42 | 43 | func setupFunc(fn func(ctx context.Context, app *App) error) step { 44 | return &stepFn{fn: fn} 45 | } 46 | 47 | type stepFn struct { 48 | fn func(ctx context.Context, app *App) error 49 | } 50 | 51 | func (s *stepFn) setup(ctx context.Context, app *App) error { 52 | return s.fn(ctx, app) 53 | } 54 | 55 | func (s *stepFn) teardown(ctx context.Context, app *App) error { 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /cli/app/step_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "testing" 10 | 11 | "github.com/asdine/lobby/log" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func appHelper(t *testing.T) (*App, func()) { 16 | dir, err := ioutil.TempDir("", "lobby") 17 | require.NoError(t, err) 18 | 19 | var app App 20 | app.out = ioutil.Discard 21 | app.Logger = log.New(log.Output(ioutil.Discard)) 22 | app.errc = make(chan error) 23 | app.Config.Paths.DataDir = path.Join(dir, "data") 24 | app.Config.Paths.SocketDir = path.Join(app.Config.Paths.DataDir, "sockets") 25 | err = app.Config.Paths.Create() 26 | require.NoError(t, err) 27 | 28 | return &app, func() { 29 | os.RemoveAll(dir) 30 | } 31 | } 32 | 33 | func TestSteps(t *testing.T) { 34 | t.Run("Cancelation", func(t *testing.T) { 35 | ctx, cancel := context.WithCancel(context.Background()) 36 | 37 | s := steps([]step{ 38 | &mockStep{ 39 | setupFn: func(ctx context.Context, app *App) error { 40 | cancel() 41 | return nil 42 | }, 43 | }, 44 | &mockStep{ 45 | setupFn: func(ctx context.Context, app *App) error { 46 | t.Error("Should not be called") 47 | return nil 48 | }, 49 | }, 50 | }) 51 | 52 | err := s.setup(ctx, nil) 53 | require.Equal(t, context.Canceled, err) 54 | }) 55 | 56 | t.Run("ReturnOnError", func(t *testing.T) { 57 | s := steps([]step{ 58 | &mockStep{ 59 | setupFn: func(ctx context.Context, app *App) error { 60 | return nil 61 | }, 62 | }, 63 | &mockStep{ 64 | setupFn: func(ctx context.Context, app *App) error { 65 | return errors.New("unexpected error") 66 | }, 67 | }, 68 | &mockStep{ 69 | setupFn: func(ctx context.Context, app *App) error { 70 | t.Error("Should not be called") 71 | return nil 72 | }, 73 | }, 74 | }) 75 | 76 | err := s.setup(context.Background(), nil) 77 | require.EqualError(t, err, "unexpected error") 78 | }) 79 | 80 | t.Run("ErrorsOnTeardown", func(t *testing.T) { 81 | s := steps([]step{ 82 | &mockStep{ 83 | teardownFn: func(ctx context.Context, app *App) error { 84 | return errors.New("3") 85 | }, 86 | }, 87 | &mockStep{ 88 | teardownFn: func(ctx context.Context, app *App) error { 89 | return errors.New("2") 90 | }, 91 | }, 92 | &mockStep{ 93 | teardownFn: func(ctx context.Context, app *App) error { 94 | return errors.New("1") 95 | }, 96 | }, 97 | }) 98 | 99 | err := s.setup(context.Background(), nil) 100 | require.NoError(t, err) 101 | errs := s.teardown(context.Background(), nil) 102 | require.Len(t, errs, 3) 103 | require.EqualError(t, errs[0], "1") 104 | require.EqualError(t, errs[1], "2") 105 | require.EqualError(t, errs[2], "3") 106 | }) 107 | 108 | t.Run("OK", func(t *testing.T) { 109 | s := steps([]step{ 110 | &mockStep{ 111 | setupFn: func(ctx context.Context, app *App) error { 112 | return nil 113 | }, 114 | }, 115 | &mockStep{ 116 | setupFn: func(ctx context.Context, app *App) error { 117 | return nil 118 | }, 119 | }, 120 | }) 121 | 122 | err := s.setup(context.Background(), nil) 123 | require.NoError(t, err) 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /cli/cmd.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/asdine/lobby/cli/app" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // New returns the lobby CLI application. 12 | func New() *cobra.Command { 13 | var app app.App 14 | cmd := newRootCmd(&app) 15 | setCoreCmd(cmd.Command, &app) 16 | return cmd.Command 17 | } 18 | 19 | func newRootCmd(app *app.App) *rootCmd { 20 | var cfgMeta toml.MetaData 21 | 22 | cmd := cobra.Command{ 23 | Use: "lobby", 24 | SilenceUsage: true, 25 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 26 | if app.ConfigPath == "" { 27 | return nil 28 | } 29 | 30 | f, err := os.Open(app.ConfigPath) 31 | if err != nil { 32 | return err 33 | } 34 | defer f.Close() 35 | 36 | cfgMeta, err = toml.DecodeReader(f, &app.Config) 37 | return err 38 | }, 39 | } 40 | 41 | cmd.PersistentFlags().StringVarP(&app.ConfigPath, "config-file", "c", "", "Path to the Lobby config file") 42 | cmd.PersistentFlags().StringVar(&app.Config.Paths.DataDir, "data-dir", ".lobby", "Path to Lobby data files") 43 | cmd.PersistentFlags().BoolVar(&app.Config.Debug, "debug", false, "Enable debug mode") 44 | 45 | return &rootCmd{ 46 | Command: &cmd, 47 | cfgMeta: &cfgMeta, 48 | } 49 | } 50 | 51 | type rootCmd struct { 52 | *cobra.Command 53 | cfgMeta *toml.MetaData 54 | } 55 | -------------------------------------------------------------------------------- /cli/plugin.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | stdlog "log" 6 | "net" 7 | "os" 8 | "os/signal" 9 | "path" 10 | "sync" 11 | "syscall" 12 | 13 | "github.com/asdine/lobby" 14 | cliapp "github.com/asdine/lobby/cli/app" 15 | "github.com/asdine/lobby/log" 16 | "github.com/asdine/lobby/rpc" 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | // RunBackend runs a plugin as a backend. 21 | func RunBackend(name string, fn func() (lobby.Backend, error), cfg interface{}) { 22 | var app cliapp.App 23 | root := newRootCmd(&app) 24 | root.Use = fmt.Sprintf("lobby-%s", name) 25 | root.Short = fmt.Sprintf("%s plugin", name) 26 | root.RunE = func(cmd *cobra.Command, args []string) error { 27 | var wg sync.WaitGroup 28 | 29 | if cfg != nil { 30 | if root.cfgMeta.IsDefined("plugins", "config", name) { 31 | err := root.cfgMeta.PrimitiveDecode(app.Config.Plugins.Config[name], cfg) 32 | if err != nil { 33 | return err 34 | } 35 | } 36 | } 37 | 38 | err := app.Config.Paths.Create() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | bck, err := fn() 44 | if err != nil { 45 | return err 46 | } 47 | defer bck.Close() 48 | 49 | ch := make(chan os.Signal, 1) 50 | signal.Notify(ch, os.Interrupt, syscall.SIGTERM) 51 | 52 | l, err := net.Listen("unix", path.Join(app.Config.Paths.SocketDir, fmt.Sprintf("%s.sock", name))) 53 | if err != nil { 54 | return err 55 | } 56 | defer l.Close() 57 | 58 | stdlog.SetFlags(0) 59 | srv := rpc.NewServer(log.New(), rpc.WithTopicService(bck)) 60 | 61 | wg.Add(1) 62 | go func() { 63 | defer wg.Done() 64 | _ = srv.Serve(l) 65 | }() 66 | 67 | <-ch 68 | err = srv.Stop() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | wg.Wait() 74 | return nil 75 | } 76 | 77 | err := root.Execute() 78 | if err != nil { 79 | os.Exit(1) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /cli/run.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/asdine/lobby/cli/app" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func setCoreCmd(cmd *cobra.Command, app *app.App) { 15 | cmd.Short = "start a lobby server" 16 | cmd.RunE = func(cmd *cobra.Command, args []string) error { 17 | ctx, cancel := context.WithCancel(context.Background()) 18 | defer cancel() 19 | 20 | errc := make(chan error) 21 | quit := make(chan os.Signal, 1) 22 | signal.Notify(quit, os.Interrupt, syscall.SIGTERM) 23 | 24 | go func() { 25 | errc <- app.Run(ctx) 26 | }() 27 | 28 | var err error 29 | 30 | select { 31 | case sig := <-quit: 32 | fmt.Println() 33 | app.Logger.Printf("Received %s signal. Shutting down...\n", sig) 34 | cancel() 35 | err = <-errc 36 | case err = <-errc: 37 | } 38 | 39 | app.Logger.Println("Shutdown complete") 40 | return err 41 | } 42 | 43 | cmd.Flags().StringSliceVar(&app.Config.Plugins.Backends, "backend", nil, "Name of the backend to use") 44 | cmd.Flags().StringVar(&app.Config.Paths.PluginDir, "plugin-dir", "", "Location of plugins") 45 | cmd.Flags().IntVar(&app.Config.Grpc.Port, "grpc-port", 5656, "gRPC API port to listen on") 46 | cmd.Flags().IntVar(&app.Config.HTTP.Port, "http-port", 5657, "HTTP API port to listen on") 47 | } 48 | -------------------------------------------------------------------------------- /cmd/lobby/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/asdine/lobby/cli" 7 | ) 8 | 9 | func main() { 10 | err := cli.New().Execute() 11 | if err != nil { 12 | os.Exit(1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | etcd: 4 | image: "quay.io/coreos/etcd" 5 | ports: 6 | - "2379:2379" 7 | command: /usr/local/bin/etcd -advertise-client-urls http://0.0.0.0:2379 -listen-client-urls http://0.0.0.0:2379 8 | mongo: 9 | image: "mongo" 10 | ports: 11 | - "27017:27017" 12 | redis: 13 | image: "redis" 14 | ports: 15 | - "6379:6379" 16 | nsqlookupd: 17 | image: nsqio/nsq 18 | command: /nsqlookupd 19 | ports: 20 | - "4160:4160" 21 | - "4161:4161" 22 | nsqd: 23 | image: nsqio/nsq 24 | command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 25 | depends_on: 26 | - nsqlookupd 27 | ports: 28 | - "4150:4150" 29 | - "4151:4151" 30 | nsqadmin: 31 | image: nsqio/nsq 32 | command: /nsqadmin --lookupd-http-address=nsqlookupd:4161 33 | depends_on: 34 | - nsqlookupd 35 | ports: 36 | - "4171:4171" 37 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package lobby 2 | 3 | // Error represents a Lobby error. 4 | type Error string 5 | 6 | // Error returns the error message. 7 | func (e Error) Error() string { 8 | return string(e) 9 | } 10 | -------------------------------------------------------------------------------- /etcd/etcdpb/etcdpb.go: -------------------------------------------------------------------------------- 1 | package etcdpb 2 | 3 | //go:generate protoc --go_out=. topic.proto 4 | -------------------------------------------------------------------------------- /etcd/etcdpb/topic.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. 2 | // source: topic.proto 3 | // DO NOT EDIT! 4 | 5 | /* 6 | Package etcdpb is a generated protocol buffer package. 7 | 8 | It is generated from these files: 9 | topic.proto 10 | 11 | It has these top-level messages: 12 | Topic 13 | */ 14 | package etcdpb 15 | 16 | import proto "github.com/golang/protobuf/proto" 17 | import fmt "fmt" 18 | import math "math" 19 | 20 | // Reference imports to suppress errors if they are not otherwise used. 21 | var _ = proto.Marshal 22 | var _ = fmt.Errorf 23 | var _ = math.Inf 24 | 25 | // This is a compile-time assertion to ensure that this generated file 26 | // is compatible with the proto package it is being compiled against. 27 | // A compilation error at this line likely means your copy of the 28 | // proto package needs to be updated. 29 | const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package 30 | 31 | type Topic struct { 32 | Name string `protobuf:"bytes,1,opt,name=Name" json:"Name,omitempty"` 33 | Backend string `protobuf:"bytes,2,opt,name=Backend" json:"Backend,omitempty"` 34 | } 35 | 36 | func (m *Topic) Reset() { *m = Topic{} } 37 | func (m *Topic) String() string { return proto.CompactTextString(m) } 38 | func (*Topic) ProtoMessage() {} 39 | func (*Topic) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } 40 | 41 | func init() { 42 | proto.RegisterType((*Topic)(nil), "etcdpb.Topic") 43 | } 44 | 45 | func init() { proto.RegisterFile("topic.proto", fileDescriptor0) } 46 | 47 | var fileDescriptor0 = []byte{ 48 | // 90 bytes of a gzipped FileDescriptorProto 49 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2e, 0xc9, 0x2f, 0xc8, 50 | 0x4c, 0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x4b, 0x2d, 0x49, 0x4e, 0x29, 0x48, 0x52, 51 | 0x32, 0xe5, 0x62, 0x0d, 0x01, 0x09, 0x0b, 0x09, 0x71, 0xb1, 0xf8, 0x25, 0xe6, 0xa6, 0x4a, 0x30, 52 | 0x2a, 0x30, 0x6a, 0x70, 0x06, 0x81, 0xd9, 0x42, 0x12, 0x5c, 0xec, 0x4e, 0x89, 0xc9, 0xd9, 0xa9, 53 | 0x79, 0x29, 0x12, 0x4c, 0x60, 0x61, 0x18, 0x37, 0x89, 0x0d, 0x6c, 0x8a, 0x31, 0x20, 0x00, 0x00, 54 | 0xff, 0xff, 0x53, 0xcf, 0x0f, 0x7e, 0x54, 0x00, 0x00, 0x00, 55 | } 56 | -------------------------------------------------------------------------------- /etcd/etcdpb/topic.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package etcdpb; 4 | 5 | message Topic { 6 | string Name = 1; 7 | string Backend = 2; 8 | } 9 | -------------------------------------------------------------------------------- /etcd/registry.go: -------------------------------------------------------------------------------- 1 | package etcd 2 | 3 | import ( 4 | "context" 5 | "path" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/asdine/lobby" 11 | "github.com/asdine/lobby/etcd/etcdpb" 12 | "github.com/asdine/lobby/log" 13 | "github.com/coreos/etcd/clientv3" 14 | "github.com/coreos/etcd/mvcc/mvccpb" 15 | "github.com/gogo/protobuf/proto" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | // NewRegistry returns an etcd Registry. 20 | func NewRegistry(client *clientv3.Client, logger *log.Logger, namespace string) (*Registry, error) { 21 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 22 | defer cancel() 23 | 24 | namespace = path.Join(strings.TrimLeft(namespace, "/"), "/") 25 | topicsPrefix := path.Join(namespace, "topics") + "/" 26 | reg := Registry{ 27 | logger: logger, 28 | client: client, 29 | namespace: namespace, 30 | topicsPrefix: topicsPrefix, 31 | backends: make(map[string]lobby.Backend), 32 | topics: &topics{ 33 | topics: make(map[string]*etcdpb.Topic), 34 | }, 35 | } 36 | 37 | resp, err := client.Get(ctx, topicsPrefix, clientv3.WithPrefix()) 38 | if err != nil { 39 | return nil, errors.Wrapf(err, "failed to retrieve topics at path '%s'", topicsPrefix) 40 | } 41 | 42 | for _, kv := range resp.Kvs { 43 | err := reg.storeTopic(kv.Key, kv.Value) 44 | if err != nil { 45 | return nil, err 46 | } 47 | } 48 | 49 | reg.topicsWatcher = clientv3.NewWatcher(client) 50 | wch := reg.topicsWatcher.Watch(context.Background(), topicsPrefix, clientv3.WithPrefix()) 51 | 52 | reg.wg.Add(1) 53 | go reg.watchTopics(wch) 54 | 55 | return ®, nil 56 | } 57 | 58 | // Registry is an etcd registry. 59 | type Registry struct { 60 | client *clientv3.Client 61 | logger *log.Logger 62 | namespace string 63 | topicsPrefix string 64 | topicsWatcher clientv3.Watcher 65 | topics *topics 66 | wg sync.WaitGroup 67 | backends map[string]lobby.Backend 68 | } 69 | 70 | // RegisterBackend registers a backend under the given name. 71 | func (r *Registry) RegisterBackend(name string, backend lobby.Backend) { 72 | r.backends[name] = backend 73 | r.logger.Debugf("Registered %s backend\n", name) 74 | } 75 | 76 | func (r *Registry) watchTopics(c clientv3.WatchChan) { 77 | defer r.wg.Done() 78 | 79 | for wresp := range c { 80 | r.logger.Debugf("Synchronizing %d topic events\n", len(wresp.Events)) 81 | for _, ev := range wresp.Events { 82 | switch ev.Type { 83 | case mvccpb.PUT: 84 | err := r.storeTopic(ev.Kv.Key, ev.Kv.Value) 85 | if err != nil { 86 | r.logger.Printf("Can't decode topic %s from etcd registry\n", ev.Kv.Key) 87 | } else { 88 | r.logger.Debugf("Synchronizing new topic %s from etcd registry\n", ev.Kv.Key) 89 | } 90 | case mvccpb.DELETE: 91 | k := string(ev.Kv.Key) 92 | r.topics.delete(k) 93 | r.logger.Debugf("Deleting topic %s\n", k) 94 | } 95 | } 96 | } 97 | } 98 | 99 | func (r *Registry) storeTopic(key, value []byte) error { 100 | var t etcdpb.Topic 101 | if err := proto.Unmarshal(value, &t); err != nil { 102 | return err 103 | } 104 | 105 | name := strings.TrimPrefix(string(key), r.topicsPrefix) 106 | r.topics.set(name, &t) 107 | return nil 108 | } 109 | 110 | // Create a topic in the registry. 111 | func (r *Registry) Create(backendName, topicName string) error { 112 | if _, ok := r.backends[backendName]; !ok { 113 | return lobby.ErrBackendNotFound 114 | } 115 | 116 | topic := etcdpb.Topic{ 117 | Name: topicName, 118 | Backend: backendName, 119 | } 120 | 121 | exists := r.topics.setIfNotExist(topicName, &topic) 122 | if exists { 123 | return lobby.ErrTopicAlreadyExists 124 | } 125 | 126 | raw, err := proto.Marshal(&topic) 127 | if err != nil { 128 | return errors.Wrapf(err, "failed to encode topic %s", topicName) 129 | } 130 | 131 | _, err = r.client.Put(context.Background(), path.Join(r.topicsPrefix, topicName), string(raw)) 132 | return errors.Wrapf(err, "failed to create topic %s", topicName) 133 | } 134 | 135 | // Topic returns the selected topic from the Backend. 136 | func (r *Registry) Topic(name string) (lobby.Topic, error) { 137 | topic, ok := r.topics.get(name) 138 | if !ok { 139 | return nil, lobby.ErrTopicNotFound 140 | } 141 | 142 | backend, ok := r.backends[topic.Backend] 143 | if !ok { 144 | return nil, lobby.ErrTopicNotFound 145 | } 146 | 147 | return backend.Topic(name) 148 | } 149 | 150 | // Close etcd connection and registered backends. 151 | func (r *Registry) Close() error { 152 | defer r.wg.Wait() 153 | 154 | for name, backend := range r.backends { 155 | err := backend.Close() 156 | if err != nil { 157 | return errors.Wrapf(err, "failed to close backend %s", name) 158 | } 159 | 160 | r.logger.Debugf("Stopped %s backend\n", name) 161 | } 162 | 163 | err := r.topicsWatcher.Close() 164 | if err != context.Canceled { 165 | return errors.Wrap(err, "failed to close etcd watcher") 166 | } 167 | return nil 168 | } 169 | 170 | type topics struct { 171 | sync.RWMutex 172 | topics map[string]*etcdpb.Topic 173 | } 174 | 175 | func (t *topics) set(k string, v *etcdpb.Topic) { 176 | t.Lock() 177 | t.topics[k] = v 178 | t.Unlock() 179 | } 180 | 181 | func (t *topics) setIfNotExist(k string, v *etcdpb.Topic) (exists bool) { 182 | t.Lock() 183 | _, exists = t.topics[k] 184 | if !exists { 185 | t.topics[k] = v 186 | } 187 | t.Unlock() 188 | return 189 | } 190 | 191 | func (t *topics) get(k string) (*etcdpb.Topic, bool) { 192 | t.RLock() 193 | tp, ok := t.topics[k] 194 | t.RUnlock() 195 | return tp, ok 196 | } 197 | 198 | func (t *topics) delete(k string) { 199 | t.Lock() 200 | delete(t.topics, k) 201 | t.Unlock() 202 | } 203 | 204 | func (t *topics) size() int { 205 | t.RLock() 206 | size := len(t.topics) 207 | t.RUnlock() 208 | return size 209 | } 210 | -------------------------------------------------------------------------------- /etcd/registry_test.go: -------------------------------------------------------------------------------- 1 | package etcd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "testing" 8 | "time" 9 | 10 | "github.com/asdine/lobby" 11 | "github.com/asdine/lobby/etcd/etcdpb" 12 | "github.com/asdine/lobby/log" 13 | "github.com/asdine/lobby/mock" 14 | "github.com/coreos/etcd/clientv3" 15 | "github.com/gogo/protobuf/proto" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | var ( 21 | dialTimeout = 5 * time.Second 22 | endpoints = []string{"localhost:2379"} 23 | ) 24 | 25 | var _ lobby.Registry = new(Registry) 26 | 27 | func etcdHelper(t require.TestingT) (*clientv3.Client, func()) { 28 | cli, err := clientv3.New(clientv3.Config{ 29 | Endpoints: endpoints, 30 | DialTimeout: dialTimeout, 31 | }) 32 | require.NoError(t, err) 33 | 34 | return cli, func() { 35 | _, err := cli.Delete(context.Background(), "lobby-tests", clientv3.WithPrefix()) 36 | assert.NoError(t, err) 37 | cli.Close() 38 | } 39 | } 40 | 41 | func TestEtcdRegistry(t *testing.T) { 42 | client, cleanup := etcdHelper(t) 43 | defer cleanup() 44 | 45 | createTopics(t, client, "lobby-tests", 5) 46 | 47 | reg, err := NewRegistry(client, log.New(log.Output(ioutil.Discard)), "lobby-tests") 48 | require.NoError(t, err) 49 | require.Equal(t, reg.topics.size(), 5) 50 | 51 | reg.RegisterBackend("backend", new(mock.Backend)) 52 | err = reg.Create("backend", "sometopic") 53 | require.NoError(t, err) 54 | require.Equal(t, reg.topics.size(), 6) 55 | 56 | err = reg.Create("backend", "sometopic") 57 | require.Equal(t, lobby.ErrTopicAlreadyExists, err) 58 | require.Equal(t, reg.topics.size(), 6) 59 | 60 | _, err = reg.Topic("sometopic") 61 | require.NoError(t, err) 62 | 63 | err = reg.Close() 64 | require.NoError(t, err) 65 | } 66 | 67 | func createTopics(t require.TestingT, client *clientv3.Client, namespace string, count int) { 68 | for i := 0; i < count; i++ { 69 | key := fmt.Sprintf("%s/topics/topic-%d", namespace, i) 70 | raw, err := proto.Marshal(&etcdpb.Topic{ 71 | Name: fmt.Sprintf("topic-%d", i), 72 | Backend: "backend", 73 | }) 74 | require.NoError(t, err) 75 | _, err = client.Put(context.Background(), key, string(raw)) 76 | require.NoError(t, err) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /etcd/topic.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package etcd; 4 | 5 | message Topic { 6 | string Name = 1; 7 | string Backend = 2; 8 | } 9 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: 8dddb522ec93df874f42f56351fe9d7778f3cff92d93b18b8605c6bb5fcd47e8 2 | updated: 2017-12-26T19:12:31.996093239+01:00 3 | imports: 4 | - name: github.com/asaskevich/govalidator 5 | version: 521b25f4b05fd26bec69d9dedeb8f9c9a83939a8 6 | - name: github.com/asdine/storm 7 | version: dbd37722730b6cb703b5bd825c3f142d87358525 8 | subpackages: 9 | - codec 10 | - codec/json 11 | - codec/protobuf 12 | - index 13 | - internal 14 | - q 15 | - name: github.com/BurntSushi/toml 16 | version: b26d9c308763d68093482582cea63d69be07a0f0 17 | - name: github.com/coreos/bbolt 18 | version: 48ea1b39c25fc1bab3506fbc712ecbaa842c4d2d 19 | - name: github.com/coreos/etcd 20 | version: f7a395f0308d6c921937b3b5d7c716ffb8018e9e 21 | subpackages: 22 | - auth/authpb 23 | - clientv3 24 | - etcdserver/api/v3rpc/rpctypes 25 | - etcdserver/etcdserverpb 26 | - mvcc/mvccpb 27 | - name: github.com/garyburd/redigo 28 | version: 47dc60e71eed504e3ef8e77ee3c6fe720f3be57f 29 | subpackages: 30 | - internal 31 | - redis 32 | - name: github.com/gogo/protobuf 33 | version: 342cbe0a04158f6dcb03ca0079991a51a4248c02 34 | subpackages: 35 | - gogoproto 36 | - proto 37 | - protoc-gen-gogo/descriptor 38 | - name: github.com/golang/protobuf 39 | version: 1e59b77b52bf8e4b449a57e6f79f21226d571845 40 | subpackages: 41 | - proto 42 | - ptypes 43 | - ptypes/any 44 | - ptypes/duration 45 | - ptypes/timestamp 46 | - name: github.com/golang/snappy 47 | version: 553a641470496b2327abcac10b36396bd98e45c9 48 | - name: github.com/grpc-ecosystem/go-grpc-middleware 49 | version: 967bee733a734780623ac3d7c8e9216e4372ea62 50 | subpackages: 51 | - grpc_recovery 52 | - name: github.com/inconshreveable/mousetrap 53 | version: 76626ae9c91c4f2a10f34cad8ce83ea42c93bb75 54 | - name: github.com/julienschmidt/httprouter 55 | version: 8c199fb6259ffc1af525cc3ad52ee60ba8359669 56 | - name: github.com/nsqio/go-nsq 57 | version: eee57a3ac4174c55924125bb15eeeda8cffb6e6f 58 | - name: github.com/pkg/errors 59 | version: 645ef00459ed84a119197bfb8d8205042c6df63d 60 | - name: github.com/spf13/cobra 61 | version: 7b2c5ac9fc04fc5efafb60700713d4fa609b777b 62 | - name: github.com/spf13/pflag 63 | version: 4c012f6dcd9546820e378d0bdda4d8fc772cdfea 64 | - name: golang.org/x/net 65 | version: 66aacef3dd8a676686c7ae3716979581e8b03c47 66 | subpackages: 67 | - context 68 | - http2 69 | - http2/hpack 70 | - idna 71 | - internal/timeseries 72 | - lex/httplex 73 | - trace 74 | - name: golang.org/x/sys 75 | version: ebfc5b4631820b793c9010c87fd8fef0f39eb082 76 | subpackages: 77 | - unix 78 | - name: golang.org/x/text 79 | version: b19bf474d317b857955b12035d2c5acb57ce8b01 80 | subpackages: 81 | - secure/bidirule 82 | - transform 83 | - unicode/bidi 84 | - unicode/norm 85 | - name: google.golang.org/genproto 86 | version: 09f6ed296fc66555a25fe4ce95173148778dfa85 87 | subpackages: 88 | - googleapis/rpc/status 89 | - name: google.golang.org/grpc 90 | version: e687fa4e6424368ece6e4fe727cea2c806a0fcb4 91 | subpackages: 92 | - balancer 93 | - balancer/roundrobin 94 | - codes 95 | - connectivity 96 | - credentials 97 | - encoding 98 | - grpclb/grpc_lb_v1/messages 99 | - grpclog 100 | - health/grpc_health_v1 101 | - internal 102 | - keepalive 103 | - metadata 104 | - naming 105 | - peer 106 | - resolver 107 | - resolver/dns 108 | - resolver/passthrough 109 | - stats 110 | - status 111 | - tap 112 | - transport 113 | - name: gopkg.in/mgo.v2 114 | version: 3f83fa5005286a7fe593b055f0d7771a7dce4655 115 | subpackages: 116 | - bson 117 | - internal/json 118 | - internal/sasl 119 | - internal/scram 120 | testImports: 121 | - name: github.com/davecgh/go-spew 122 | version: 6d212800a42e8ab5c146b8ace3490ee17e5225f9 123 | subpackages: 124 | - spew 125 | - name: github.com/pmezard/go-difflib 126 | version: d8ed2627bdf02c080bf22230dbb337003b7aba2d 127 | subpackages: 128 | - difflib 129 | - name: github.com/stretchr/testify 130 | version: 69483b4bd14f5845b5a1e55bca19e954e827f1d0 131 | subpackages: 132 | - assert 133 | - require 134 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/asdine/lobby 2 | import: 3 | - package: github.com/BurntSushi/toml 4 | version: ^0.3.0 5 | - package: github.com/asaskevich/govalidator 6 | version: ^8.0.0 7 | - package: github.com/asdine/storm 8 | version: ^2.0.0 9 | subpackages: 10 | - codec/protobuf 11 | - package: github.com/coreos/bbolt 12 | version: ^1.3.1-coreos.6 13 | - package: github.com/coreos/etcd 14 | version: ^3.3.0-rc.0 15 | subpackages: 16 | - clientv3 17 | - mvcc/mvccpb 18 | - package: github.com/garyburd/redigo 19 | version: ^1.3.0 20 | subpackages: 21 | - redis 22 | - package: github.com/gogo/protobuf 23 | version: ^0.5.0 24 | subpackages: 25 | - proto 26 | - package: github.com/golang/protobuf 27 | subpackages: 28 | - proto 29 | - package: github.com/julienschmidt/httprouter 30 | version: ^1.1.0 31 | - package: github.com/nsqio/go-nsq 32 | version: ^1.0.7 33 | - package: github.com/pkg/errors 34 | version: ^0.8.0 35 | - package: github.com/spf13/cobra 36 | version: ^0.0.1 37 | - package: golang.org/x/net 38 | subpackages: 39 | - context 40 | - package: google.golang.org/grpc 41 | version: ^1.8.2 42 | subpackages: 43 | - codes 44 | - status 45 | - package: gopkg.in/mgo.v2 46 | - package: github.com/grpc-ecosystem/go-grpc-middleware 47 | subpackages: 48 | - grpc_recovery 49 | testImport: 50 | - package: github.com/stretchr/testify 51 | version: ^1.1.4 52 | subpackages: 53 | - assert 54 | - require 55 | -------------------------------------------------------------------------------- /http/errors.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/asdine/lobby/log" 9 | "github.com/asdine/lobby/validation" 10 | ) 11 | 12 | // HTTP errors 13 | const ( 14 | errInvalidJSON = lobby.Error("invalid_json") 15 | errInternal = lobby.Error("internal_error") 16 | errEmptyContent = lobby.Error("empty_content") 17 | ) 18 | 19 | // writeError writes an API error message to the response and logger. 20 | func writeError(w http.ResponseWriter, err error, code int, logger *log.Logger) { 21 | // Log error. 22 | logger.Debugf("http error: %s (code=%d)", err, code) 23 | 24 | // Hide error from client if it is internal. 25 | if code == http.StatusInternalServerError { 26 | err = errInternal 27 | } 28 | 29 | w.WriteHeader(code) 30 | if err == nil { 31 | return 32 | } 33 | 34 | w.Header().Set("Content-Type", "application/json") 35 | 36 | enc := json.NewEncoder(w) 37 | switch { 38 | case validation.IsError(err): 39 | err = enc.Encode(&validationErrorResponse{ 40 | Err: "validation error", 41 | Fields: err, 42 | }) 43 | default: 44 | err = enc.Encode(&errorResponse{Err: err.Error()}) 45 | } 46 | 47 | logger.Println(err) 48 | } 49 | 50 | // errorResponse is a generic response for sending an error. 51 | type errorResponse struct { 52 | Err string `json:"err,omitempty"` 53 | } 54 | 55 | // validationErrorResponse is used for validation errors. 56 | type validationErrorResponse struct { 57 | Err string `json:"err,omitempty"` 58 | Fields error `json:"fields"` 59 | } 60 | -------------------------------------------------------------------------------- /http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/asdine/lobby" 13 | "github.com/asdine/lobby/log" 14 | "github.com/asdine/lobby/validation" 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | const maxBodySize = 1024 * 1024 19 | 20 | // NewServer returns an http lobby server. 21 | func NewServer(handler http.Handler) lobby.Server { 22 | return &Server{ 23 | Server: &http.Server{ 24 | Handler: handler, 25 | }, 26 | } 27 | } 28 | 29 | // Server wraps an HTTP server. 30 | type Server struct { 31 | *http.Server 32 | } 33 | 34 | // Name of the server. 35 | func (s *Server) Name() string { 36 | return "http" 37 | } 38 | 39 | // Serve incoming requests. 40 | func (s *Server) Serve(l net.Listener) error { 41 | err := s.Server.Serve(l) 42 | if err != nil && err != http.ErrServerClosed { 43 | return err 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // Stop gracefully stops the server. 50 | func (s *Server) Stop() error { 51 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 52 | defer cancel() 53 | return s.Server.Shutdown(ctx) 54 | } 55 | 56 | type wrapper struct { 57 | handler http.Handler 58 | logger *log.Logger 59 | } 60 | 61 | // ServeHTTP delegates a request to the underlying handler. 62 | func (s *wrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { 63 | start := time.Now() 64 | 65 | rw := newResponseWriter(w) 66 | 67 | if r.ContentLength > maxBodySize { 68 | w.WriteHeader(http.StatusRequestEntityTooLarge) 69 | } else { 70 | s.handler.ServeHTTP(rw, r) 71 | } 72 | 73 | s.logger.Debugf( 74 | "%s %s %s %d %d %s", 75 | clientIP(r), 76 | r.Method, 77 | r.URL, 78 | rw.status, 79 | rw.len, 80 | time.Since(start), 81 | ) 82 | } 83 | 84 | // newResponseWriter instantiates a responseWriter. 85 | func newResponseWriter(w http.ResponseWriter) *responseWriter { 86 | return &responseWriter{ 87 | ResponseWriter: w, 88 | status: http.StatusOK, 89 | } 90 | } 91 | 92 | // responseWriter is a wrapper around http.ResponseWriter. 93 | // It allows to capture informations about the response. 94 | type responseWriter struct { 95 | http.ResponseWriter 96 | 97 | status int 98 | len int 99 | } 100 | 101 | // WriteHeader stores the status before calling the underlying 102 | // http.ResponseWriter WriteHeader. 103 | func (w *responseWriter) WriteHeader(status int) { 104 | w.status = status 105 | w.ResponseWriter.WriteHeader(status) 106 | } 107 | 108 | func (w *responseWriter) Write(data []byte) (int, error) { 109 | w.len = len(data) 110 | return w.ResponseWriter.Write(data) 111 | } 112 | 113 | // encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. 114 | func encodeJSON(w http.ResponseWriter, v interface{}, status int, logger *log.Logger) { 115 | w.Header().Set("Content-Type", "application/json") 116 | w.WriteHeader(status) 117 | if err := json.NewEncoder(w).Encode(v); err != nil { 118 | writeError(w, err, http.StatusInternalServerError, logger) 119 | } 120 | } 121 | 122 | // encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. 123 | func writeRawJSON(w http.ResponseWriter, v []byte, status int, logger *log.Logger) { 124 | w.Header().Set("Content-Type", "application/json") 125 | w.WriteHeader(status) 126 | if _, err := w.Write(v); err != nil { 127 | writeError(w, err, http.StatusInternalServerError, logger) 128 | } 129 | } 130 | 131 | // NewHandler instantiates a configured Handler. 132 | func NewHandler(r lobby.Registry, logger *log.Logger) http.Handler { 133 | router := httprouter.New() 134 | 135 | h := handler{ 136 | registry: r, 137 | logger: logger, 138 | router: router, 139 | } 140 | 141 | router.POST("/v1/topics", h.createTopic) 142 | router.POST("/v1/topics/:topic", h.postMessage) 143 | router.POST("/v1/topics/:topic/:group", h.postMessage) 144 | return &wrapper{handler: router, logger: h.logger} 145 | } 146 | 147 | type handler struct { 148 | registry lobby.Registry 149 | router *httprouter.Router 150 | logger *log.Logger 151 | } 152 | 153 | func (h *handler) createTopic(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 154 | var req topicCreationRequest 155 | 156 | if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { 157 | writeError(w, nil, http.StatusUnsupportedMediaType, h.logger) 158 | return 159 | } 160 | 161 | err := json.NewDecoder(r.Body).Decode(&req) 162 | if err != nil { 163 | writeError(w, errInvalidJSON, http.StatusBadRequest, h.logger) 164 | return 165 | } 166 | 167 | err = req.Validate() 168 | if err != nil { 169 | writeError(w, err, http.StatusBadRequest, h.logger) 170 | return 171 | } 172 | 173 | err = h.registry.Create(req.Backend, req.Name) 174 | switch err { 175 | case nil: 176 | w.WriteHeader(http.StatusCreated) 177 | case lobby.ErrBackendNotFound: 178 | http.NotFound(w, r) 179 | case lobby.ErrTopicAlreadyExists: 180 | writeError(w, validation.AddError(nil, "name", err), http.StatusBadRequest, h.logger) 181 | default: 182 | writeError(w, err, http.StatusInternalServerError, h.logger) 183 | } 184 | } 185 | 186 | func (h *handler) postMessage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 187 | if r.ContentLength == 0 { 188 | writeError(w, errEmptyContent, http.StatusBadRequest, h.logger) 189 | return 190 | } 191 | 192 | defer r.Body.Close() 193 | value, err := ioutil.ReadAll(r.Body) 194 | if err != nil { 195 | writeError(w, err, http.StatusInternalServerError, h.logger) 196 | return 197 | } 198 | 199 | t, err := h.registry.Topic(ps.ByName("topic")) 200 | if err != nil { 201 | if err == lobby.ErrTopicNotFound { 202 | http.NotFound(w, r) 203 | return 204 | } 205 | 206 | writeError(w, err, http.StatusInternalServerError, h.logger) 207 | return 208 | } 209 | 210 | err = t.Send(&lobby.Message{ 211 | Group: ps.ByName("group"), 212 | Value: value, 213 | }) 214 | if err != nil { 215 | writeError(w, err, http.StatusInternalServerError, h.logger) 216 | return 217 | } 218 | 219 | writeRawJSON(w, nil, http.StatusCreated, h.logger) 220 | } 221 | 222 | type topicCreationRequest struct { 223 | Name string `json:"name" valid:"required,alphanum,stringlength(1|64)"` 224 | Backend string `json:"backend" valid:"required,alphanum"` 225 | } 226 | 227 | func (t *topicCreationRequest) Validate() error { 228 | t.Name = strings.TrimSpace(t.Name) 229 | t.Backend = strings.TrimSpace(t.Backend) 230 | 231 | return validation.Validate(t) 232 | } 233 | -------------------------------------------------------------------------------- /http/http_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/asdine/lobby" 14 | lobbyHttp "github.com/asdine/lobby/http" 15 | "github.com/asdine/lobby/log" 16 | "github.com/asdine/lobby/mock" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | func createTopicRequest(t *testing.T, r io.Reader) *http.Request { 21 | req, err := http.NewRequest("POST", "/v1/topics", r) 22 | require.NoError(t, err) 23 | req.Header.Set("Content-Type", "application/json") 24 | return req 25 | } 26 | 27 | func TestCreateTopic(t *testing.T) { 28 | t.Run("EmptyBody", func(t *testing.T) { 29 | var registry mock.Registry 30 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 31 | 32 | w := httptest.NewRecorder() 33 | r := createTopicRequest(t, bytes.NewReader([]byte(nil))) 34 | h.ServeHTTP(w, r) 35 | require.Equal(t, http.StatusBadRequest, w.Code) 36 | }) 37 | 38 | t.Run("InvalidJSON", func(t *testing.T) { 39 | var registry mock.Registry 40 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 41 | 42 | w := httptest.NewRecorder() 43 | r := createTopicRequest(t, strings.NewReader(`hello`)) 44 | h.ServeHTTP(w, r) 45 | require.Equal(t, http.StatusBadRequest, w.Code) 46 | }) 47 | 48 | t.Run("ValidationError", func(t *testing.T) { 49 | var registry mock.Registry 50 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 51 | 52 | w := httptest.NewRecorder() 53 | r := createTopicRequest(t, strings.NewReader(`{"name": " "}`)) 54 | h.ServeHTTP(w, r) 55 | require.Equal(t, http.StatusBadRequest, w.Code) 56 | }) 57 | 58 | t.Run("BackendNotFound", func(t *testing.T) { 59 | var registry mock.Registry 60 | 61 | registry.CreateFn = func(backendName, topicName string) error { 62 | require.Equal(t, "backend", backendName) 63 | require.Equal(t, "topic", topicName) 64 | 65 | return lobby.ErrBackendNotFound 66 | } 67 | 68 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 69 | 70 | w := httptest.NewRecorder() 71 | r := createTopicRequest(t, strings.NewReader(`{"name": " topic ", "backend": "backend"}`)) 72 | h.ServeHTTP(w, r) 73 | require.Equal(t, http.StatusNotFound, w.Code) 74 | }) 75 | 76 | t.Run("BackendConflict", func(t *testing.T) { 77 | var registry mock.Registry 78 | 79 | registry.CreateFn = func(backendName, topicName string) error { 80 | require.Equal(t, "backend", backendName) 81 | require.Equal(t, "topic", topicName) 82 | 83 | return lobby.ErrTopicAlreadyExists 84 | } 85 | 86 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 87 | 88 | w := httptest.NewRecorder() 89 | r := createTopicRequest(t, strings.NewReader(`{"name": " topic ","backend": "backend"}`)) 90 | h.ServeHTTP(w, r) 91 | require.Equal(t, http.StatusBadRequest, w.Code) 92 | }) 93 | 94 | t.Run("InternalError", func(t *testing.T) { 95 | var registry mock.Registry 96 | 97 | registry.CreateFn = func(backendName, topicName string) error { 98 | require.Equal(t, "backend", backendName) 99 | require.Equal(t, "topic", topicName) 100 | 101 | return errors.New("something unexpected happened !") 102 | } 103 | 104 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 105 | 106 | w := httptest.NewRecorder() 107 | r := createTopicRequest(t, strings.NewReader(`{"name": " topic ", "backend": "backend"}`)) 108 | h.ServeHTTP(w, r) 109 | require.Equal(t, http.StatusInternalServerError, w.Code) 110 | }) 111 | 112 | t.Run("OK", func(t *testing.T) { 113 | var registry mock.Registry 114 | 115 | registry.CreateFn = func(backendName, topicName string) error { 116 | require.Equal(t, "backend", backendName) 117 | require.Equal(t, "topic", topicName) 118 | 119 | return nil 120 | } 121 | 122 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 123 | 124 | w := httptest.NewRecorder() 125 | r := createTopicRequest(t, strings.NewReader(`{"name": " topic ", "backend": "backend"}`)) 126 | h.ServeHTTP(w, r) 127 | require.Equal(t, http.StatusCreated, w.Code) 128 | }) 129 | } 130 | 131 | func TestSaveMessage(t *testing.T) { 132 | t.Run("EmptyBody", func(t *testing.T) { 133 | var registry mock.Registry 134 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 135 | 136 | w := httptest.NewRecorder() 137 | r, _ := http.NewRequest("POST", "/v1/topics/topic/key", bytes.NewReader([]byte(nil))) 138 | h.ServeHTTP(w, r) 139 | require.Equal(t, http.StatusBadRequest, w.Code) 140 | }) 141 | 142 | t.Run("TopicNotFound", func(t *testing.T) { 143 | var registry mock.Registry 144 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 145 | 146 | registry.TopicFn = func(name string) (lobby.Topic, error) { 147 | require.Equal(t, "topic", name) 148 | 149 | return nil, lobby.ErrTopicNotFound 150 | } 151 | 152 | w := httptest.NewRecorder() 153 | r, _ := http.NewRequest("POST", "/v1/topics/topic/key", strings.NewReader(`{}`)) 154 | h.ServeHTTP(w, r) 155 | require.Equal(t, http.StatusNotFound, w.Code) 156 | }) 157 | 158 | t.Run("InternalError", func(t *testing.T) { 159 | var registry mock.Registry 160 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 161 | 162 | registry.TopicFn = func(name string) (lobby.Topic, error) { 163 | require.Equal(t, "topic", name) 164 | 165 | return nil, errors.New("something unexpected happened !") 166 | } 167 | 168 | w := httptest.NewRecorder() 169 | r, _ := http.NewRequest("POST", "/v1/topics/topic/key", strings.NewReader(`{}`)) 170 | h.ServeHTTP(w, r) 171 | require.Equal(t, http.StatusInternalServerError, w.Code) 172 | }) 173 | 174 | t.Run("OK", func(t *testing.T) { 175 | var registry mock.Registry 176 | h := lobbyHttp.NewHandler(®istry, log.New(log.Output(ioutil.Discard))) 177 | 178 | registry.TopicFn = func(name string) (lobby.Topic, error) { 179 | require.Equal(t, "topic", name) 180 | 181 | return &mock.Topic{ 182 | SendFn: func(message *lobby.Message) error { 183 | require.Equal(t, "group", message.Group) 184 | require.Equal(t, []byte(`hello`), message.Value) 185 | 186 | return nil 187 | }, 188 | }, nil 189 | } 190 | 191 | w := httptest.NewRecorder() 192 | r, _ := http.NewRequest("POST", "/v1/topics/topic/group", strings.NewReader(`hello`)) 193 | h.ServeHTTP(w, r) 194 | require.Equal(t, http.StatusCreated, w.Code) 195 | }) 196 | } 197 | -------------------------------------------------------------------------------- /http/request.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // returns the client real ip address. 10 | // inspired by https://github.com/gin-gonic/gin/blob/32cab500ecc71d2975f5699c8a65c6debb29cfbe/context.go#L341 11 | func clientIP(r *http.Request) string { 12 | clientIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")) 13 | if len(clientIP) > 0 { 14 | return clientIP 15 | } 16 | 17 | clientIP = r.Header.Get("X-Forwarded-For") 18 | if index := strings.IndexByte(clientIP, ','); index >= 0 { 19 | clientIP = clientIP[0:index] 20 | } 21 | 22 | clientIP = strings.TrimSpace(clientIP) 23 | if len(clientIP) > 0 { 24 | return clientIP 25 | } 26 | 27 | if ip, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)); err == nil { 28 | return ip 29 | } 30 | 31 | return "" 32 | } 33 | -------------------------------------------------------------------------------- /io.go: -------------------------------------------------------------------------------- 1 | package lobby 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // NewPrefixWriter creates a PrefixWriter. 9 | func NewPrefixWriter(prefix string, to io.Writer) *PrefixWriter { 10 | return &PrefixWriter{ 11 | prefix: []byte(prefix), 12 | to: to, 13 | } 14 | } 15 | 16 | // NewFuncPrefixWriter creates a PrefixWriter that uses a function to generate a prefix. 17 | func NewFuncPrefixWriter(prefixFn func() []byte, to io.Writer) *PrefixWriter { 18 | return &PrefixWriter{ 19 | prefixFn: prefixFn, 20 | to: to, 21 | } 22 | } 23 | 24 | // PrefixWriter is a writer that adds a prefix before every line. 25 | type PrefixWriter struct { 26 | to io.Writer 27 | buf bytes.Buffer 28 | prefix []byte 29 | prefixFn func() []byte 30 | } 31 | 32 | func (w *PrefixWriter) Write(p []byte) (int, error) { 33 | lenp := len(p) 34 | 35 | idx := bytes.IndexByte(p, '\n') 36 | if idx == -1 { 37 | return w.buf.Write(p) 38 | } 39 | 40 | for idx != -1 { 41 | if idx == 0 && w.buf.Len() == 0 { 42 | p = p[1:] 43 | idx = bytes.IndexByte(p, '\n') 44 | continue 45 | } 46 | 47 | idx++ 48 | 49 | n, err := w.buf.Write(p[:idx]) 50 | if err != nil { 51 | return n, err 52 | } 53 | 54 | if w.prefixFn != nil { 55 | n, err = w.to.Write(w.prefixFn()) 56 | } else { 57 | n, err = w.to.Write(w.prefix) 58 | } 59 | if err != nil { 60 | return n, err 61 | } 62 | 63 | n, err = w.to.Write(w.buf.Bytes()) 64 | if err != nil { 65 | return n, err 66 | } 67 | w.buf.Reset() 68 | 69 | p = p[idx:] 70 | idx = bytes.IndexByte(p, '\n') 71 | } 72 | 73 | if len(p) != 0 { 74 | n, err := w.buf.Write(p) 75 | if err != nil { 76 | return n, err 77 | } 78 | } 79 | 80 | return lenp, nil 81 | } 82 | -------------------------------------------------------------------------------- /io_test.go: -------------------------------------------------------------------------------- 1 | package lobby_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/asdine/lobby" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPrefixWriter(t *testing.T) { 12 | t.Run("Empty slice", func(t *testing.T) { 13 | var buf bytes.Buffer 14 | p := lobby.NewPrefixWriter("[prefix] ", &buf) 15 | n, err := p.Write([]byte("")) 16 | require.NoError(t, err) 17 | require.Zero(t, n) 18 | require.Zero(t, buf.Len()) 19 | }) 20 | 21 | t.Run("Simple line", func(t *testing.T) { 22 | var buf bytes.Buffer 23 | p := lobby.NewPrefixWriter("[prefix] ", &buf) 24 | src := []byte("Hello\n") 25 | n, err := p.Write(src) 26 | require.NoError(t, err) 27 | result := "[prefix] Hello\n" 28 | require.Equal(t, len(src), n) 29 | require.Equal(t, result, buf.String()) 30 | }) 31 | 32 | t.Run("Multi part line", func(t *testing.T) { 33 | var buf bytes.Buffer 34 | p := lobby.NewPrefixWriter("[prefix] ", &buf) 35 | src := []byte("Hello") 36 | n, err := p.Write(src) 37 | require.NoError(t, err) 38 | require.Equal(t, len(src), n) 39 | require.Zero(t, buf.Len()) 40 | 41 | src = []byte(" World\nHow are") 42 | n, err = p.Write(src) 43 | require.NoError(t, err) 44 | result := "[prefix] Hello World\n" 45 | require.Equal(t, len(src), n) 46 | require.Equal(t, result, buf.String()) 47 | buf.Reset() 48 | 49 | src = []byte(" you ?\n") 50 | n, err = p.Write(src) 51 | require.NoError(t, err) 52 | result = "[prefix] How are you ?\n" 53 | require.Equal(t, len(src), n) 54 | require.Equal(t, result, buf.String()) 55 | }) 56 | 57 | t.Run("Multiline at once", func(t *testing.T) { 58 | var buf bytes.Buffer 59 | p := lobby.NewPrefixWriter("[prefix] ", &buf) 60 | src := []byte("Hello World\nHow are you ?\nI'm") 61 | n, err := p.Write(src) 62 | require.NoError(t, err) 63 | result := "[prefix] Hello World\n[prefix] How are you ?\n" 64 | require.Equal(t, len(src), n) 65 | require.Equal(t, result, buf.String()) 66 | buf.Reset() 67 | 68 | src = []byte(" fine\n") 69 | n, err = p.Write(src) 70 | require.NoError(t, err) 71 | result = "[prefix] I'm fine\n" 72 | require.Equal(t, len(src), n) 73 | require.Equal(t, result, buf.String()) 74 | }) 75 | 76 | t.Run("Skip empty lines", func(t *testing.T) { 77 | var buf bytes.Buffer 78 | p := lobby.NewPrefixWriter("[prefix] ", &buf) 79 | src := []byte("\n") 80 | n, err := p.Write(src) 81 | require.NoError(t, err) 82 | require.Equal(t, len(src), n) 83 | require.Zero(t, buf.Len()) 84 | 85 | src = []byte("\n\n\n") 86 | n, err = p.Write(src) 87 | require.NoError(t, err) 88 | require.Equal(t, len(src), n) 89 | require.Zero(t, buf.Len()) 90 | 91 | src = []byte("Hello\n\n\n") 92 | n, err = p.Write(src) 93 | require.NoError(t, err) 94 | result := "[prefix] Hello\n" 95 | require.Equal(t, len(src), n) 96 | require.Equal(t, result, buf.String()) 97 | buf.Reset() 98 | 99 | src = []byte("Hello\n\n\n") 100 | n, err = p.Write(src) 101 | require.NoError(t, err) 102 | result = "[prefix] Hello\n" 103 | require.Equal(t, len(src), n) 104 | require.Equal(t, result, buf.String()) 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | ) 8 | 9 | type Logger struct { 10 | prefix string 11 | logger *log.Logger 12 | debugEnabled bool 13 | } 14 | 15 | func New(opts ...func(*Logger)) *Logger { 16 | var l Logger 17 | 18 | for _, o := range opts { 19 | o(&l) 20 | } 21 | 22 | if l.logger == nil { 23 | Output(os.Stderr)(&l) 24 | } 25 | 26 | return &l 27 | } 28 | 29 | func Prefix(prefix string) func(*Logger) { 30 | return func(l *Logger) { 31 | l.prefix = prefix 32 | } 33 | } 34 | 35 | func Debug(debug bool) func(*Logger) { 36 | return func(l *Logger) { 37 | l.debugEnabled = debug 38 | } 39 | } 40 | 41 | func Output(out io.Writer) func(*Logger) { 42 | return func(l *Logger) { 43 | l.logger = log.New(out, "", log.Flags()) 44 | } 45 | } 46 | 47 | func StdLogger(lg *log.Logger) func(*Logger) { 48 | return func(l *Logger) { 49 | l.logger = lg 50 | } 51 | } 52 | 53 | func (l *Logger) Println(v ...interface{}) { 54 | l.leveledPrintln("i |", v...) 55 | } 56 | 57 | func (l *Logger) leveledPrintln(level string, v ...interface{}) { 58 | if l.prefix != "" { 59 | v = append([]interface{}{level, l.prefix}, v...) 60 | } else { 61 | v = append([]interface{}{level}, v...) 62 | } 63 | 64 | l.logger.Println(v...) 65 | } 66 | 67 | func (l *Logger) leveledPrintf(level string, format string, v ...interface{}) { 68 | if l.prefix != "" { 69 | format = level + l.prefix + " " + format 70 | } else { 71 | format = level + format 72 | } 73 | 74 | l.logger.Printf(format, v...) 75 | } 76 | 77 | func (l *Logger) Printf(format string, v ...interface{}) { 78 | l.leveledPrintf("i | ", format, v...) 79 | } 80 | 81 | func (l *Logger) Debug(v ...interface{}) { 82 | if !l.debugEnabled { 83 | return 84 | } 85 | 86 | l.leveledPrintln("d |", v...) 87 | } 88 | 89 | func (l *Logger) Debugf(format string, v ...interface{}) { 90 | if !l.debugEnabled { 91 | return 92 | } 93 | 94 | l.leveledPrintf("d | ", format, v...) 95 | } 96 | -------------------------------------------------------------------------------- /log/logger_test.go: -------------------------------------------------------------------------------- 1 | package log_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | stdlog "log" 7 | "testing" 8 | 9 | "github.com/asdine/lobby/log" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestLogger(t *testing.T) { 14 | var testCases = []func(l *log.Logger){ 15 | func(l *log.Logger) { l.Println("message") }, 16 | func(l *log.Logger) { l.Printf("message\n") }, 17 | } 18 | 19 | for _, test := range testCases { 20 | t.Run("WithPrefix", func(t *testing.T) { 21 | var buff bytes.Buffer 22 | stdlog.SetFlags(0) 23 | logger := log.New(log.Output(&buff), log.Prefix("prefix")) 24 | test(logger) 25 | require.Equal(t, "i | prefix message\n", buff.String()) 26 | }) 27 | 28 | t.Run("WithoutPrefix", func(t *testing.T) { 29 | var buff bytes.Buffer 30 | stdlog.SetFlags(0) 31 | logger := log.New(log.Output(&buff)) 32 | test(logger) 33 | require.Equal(t, "i | message\n", buff.String()) 34 | }) 35 | } 36 | } 37 | 38 | func TestLoggerDebug(t *testing.T) { 39 | var testCases = []func(l *log.Logger){ 40 | func(l *log.Logger) { l.Debug("message") }, 41 | func(l *log.Logger) { l.Debugf("message\n") }, 42 | } 43 | 44 | for _, test := range testCases { 45 | t.Run("WithoutDebug", func(t *testing.T) { 46 | var buff bytes.Buffer 47 | stdlog.SetFlags(0) 48 | logger := log.New(log.Output(&buff), log.Prefix("prefix")) 49 | test(logger) 50 | require.Equal(t, "", buff.String()) 51 | }) 52 | 53 | t.Run("WithDebugAndPrefix", func(t *testing.T) { 54 | var buff bytes.Buffer 55 | stdlog.SetFlags(0) 56 | logger := log.New( 57 | log.Output(&buff), 58 | log.Prefix("prefix"), 59 | log.Debug(true), 60 | ) 61 | test(logger) 62 | require.Equal(t, "d | prefix message\n", buff.String()) 63 | }) 64 | 65 | t.Run("WithDebugNoPrefix", func(t *testing.T) { 66 | var buff bytes.Buffer 67 | stdlog.SetFlags(0) 68 | logger := log.New( 69 | log.Output(&buff), 70 | log.Debug(true), 71 | ) 72 | test(logger) 73 | require.Equal(t, "d | message\n", buff.String()) 74 | }) 75 | } 76 | } 77 | 78 | func BenchmarkLog(b *testing.B) { 79 | logger := log.New(log.Output(ioutil.Discard), log.Prefix("prefix")) 80 | vs := make([]interface{}, 5) 81 | for i := 0; i < 5; i++ { 82 | vs[i] = "foo" 83 | } 84 | 85 | b.ResetTimer() 86 | for i := 0; i < b.N; i++ { 87 | logger.Println(vs...) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /mock/backend.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/asdine/lobby" 4 | 5 | var _ lobby.Backend = new(Backend) 6 | 7 | // Backend is a mock service that runs provided functions. Useful for testing. 8 | type Backend struct { 9 | TopicFn func(name string) (lobby.Topic, error) 10 | TopicInvoked int 11 | 12 | CloseFn func() error 13 | CloseInvoked int 14 | } 15 | 16 | // Topic runs TopicFn and increments TopicInvoked when invoked. 17 | func (s *Backend) Topic(name string) (lobby.Topic, error) { 18 | s.TopicInvoked++ 19 | 20 | if s.TopicFn != nil { 21 | return s.TopicFn(name) 22 | } 23 | 24 | return nil, nil 25 | } 26 | 27 | // Close runs CloseFn and increments CloseInvoked when invoked. 28 | func (s *Backend) Close() error { 29 | s.CloseInvoked++ 30 | 31 | if s.CloseFn != nil { 32 | return s.CloseFn() 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /mock/plugin.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/asdine/lobby" 7 | ) 8 | 9 | var _ lobby.Plugin = new(Plugin) 10 | 11 | // Plugin is a mock service that runs provided functions. Useful for testing. 12 | type Plugin struct { 13 | sync.Mutex 14 | 15 | NameFn func() string 16 | NameInvoked int 17 | 18 | CloseFn func() error 19 | CloseInvoked int 20 | 21 | WaitFn func() error 22 | WaitInvoked int 23 | } 24 | 25 | // Name runs NameFn and increments NameInvoked when invoked. 26 | func (s *Plugin) Name() string { 27 | s.Lock() 28 | defer s.Unlock() 29 | s.NameInvoked++ 30 | 31 | if s.NameFn != nil { 32 | return s.NameFn() 33 | } 34 | 35 | return "mock" 36 | } 37 | 38 | // Close runs CloseFn and increments CloseInvoked when invoked. 39 | func (s *Plugin) Close() error { 40 | s.Lock() 41 | defer s.Unlock() 42 | s.CloseInvoked++ 43 | 44 | if s.CloseFn != nil { 45 | return s.CloseFn() 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // Wait runs WaitFn and increments WaitInvoked when invoked. 52 | func (s *Plugin) Wait() error { 53 | s.Lock() 54 | defer s.Unlock() 55 | s.WaitInvoked++ 56 | 57 | if s.WaitFn != nil { 58 | return s.WaitFn() 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /mock/registry.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/asdine/lobby" 4 | 5 | var _ lobby.Registry = new(Registry) 6 | 7 | // Registry is a mock service that runs provided functions. Useful for testing. 8 | type Registry struct { 9 | CreateFn func(string, string) error 10 | CreateInvoked int 11 | 12 | TopicFn func(string) (lobby.Topic, error) 13 | TopicInvoked int 14 | 15 | CloseFn func() error 16 | CloseInvoked int 17 | 18 | Backends map[string]lobby.Backend 19 | } 20 | 21 | // RegisterBackend saves the backend in the Backends map. 22 | func (r *Registry) RegisterBackend(name string, backend lobby.Backend) { 23 | if r.Backends == nil { 24 | r.Backends = make(map[string]lobby.Backend) 25 | } 26 | 27 | r.Backends[name] = backend 28 | } 29 | 30 | // Create runs CreateFn and increments CreateInvoked when invoked. 31 | func (r *Registry) Create(backendName, topicName string) error { 32 | r.CreateInvoked++ 33 | 34 | if r.CreateFn != nil { 35 | return r.CreateFn(backendName, topicName) 36 | } 37 | 38 | return nil 39 | } 40 | 41 | // Topic runs TopicFn and increments TopicInvoked when invoked. 42 | func (r *Registry) Topic(name string) (lobby.Topic, error) { 43 | r.TopicInvoked++ 44 | 45 | if r.TopicFn != nil { 46 | return r.TopicFn(name) 47 | } 48 | 49 | return nil, nil 50 | } 51 | 52 | // Close runs CloseFn and increments CloseInvoked when invoked. 53 | func (r *Registry) Close() error { 54 | r.CloseInvoked++ 55 | 56 | if r.CloseFn != nil { 57 | return r.CloseFn() 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /mock/topic.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/asdine/lobby" 4 | 5 | var _ lobby.Topic = new(Topic) 6 | 7 | // Topic is a mock service that runs provided functions. Useful for testing. 8 | type Topic struct { 9 | SendFn func(*lobby.Message) error 10 | SendInvoked int 11 | 12 | CloseFn func() error 13 | CloseInvoked int 14 | } 15 | 16 | // Send runs SendFn and increments SendInvoked when invoked. 17 | func (b *Topic) Send(message *lobby.Message) error { 18 | b.SendInvoked++ 19 | 20 | if b.SendFn != nil { 21 | return b.SendFn(message) 22 | } 23 | 24 | return nil 25 | } 26 | 27 | // Close runs CloseFn and increments CloseInvoked when invoked. 28 | func (b *Topic) Close() error { 29 | b.CloseInvoked++ 30 | 31 | if b.CloseFn != nil { 32 | return b.CloseFn() 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package lobby 2 | 3 | // Plugin is a generic lobby plugin. 4 | type Plugin interface { 5 | // Unique name of the plugin 6 | Name() string 7 | 8 | // Gracefully closes the plugin 9 | Close() error 10 | 11 | // Wait for the plugin to quit or crash. This is a blocking operation. 12 | Wait() error 13 | } 14 | -------------------------------------------------------------------------------- /rpc/backend.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/rpc/proto" 8 | "google.golang.org/grpc" 9 | ) 10 | 11 | var _ lobby.Backend = new(Backend) 12 | 13 | // NewBackend returns a gRPC backend. It is used to communicate with external backends. 14 | func NewBackend(conn *grpc.ClientConn) (*Backend, error) { 15 | client := proto.NewTopicServiceClient(conn) 16 | 17 | return &Backend{ 18 | conn: conn, 19 | client: client, 20 | }, nil 21 | } 22 | 23 | // Backend is a gRPC backend. 24 | type Backend struct { 25 | conn *grpc.ClientConn 26 | client proto.TopicServiceClient 27 | } 28 | 29 | // Topic returns the topic associated with the given name. 30 | func (s *Backend) Topic(name string) (lobby.Topic, error) { 31 | return NewTopic(name, s.client), nil 32 | } 33 | 34 | // Close does nothing. 35 | func (s *Backend) Close() error { 36 | return nil 37 | } 38 | 39 | var _ lobby.Topic = new(Topic) 40 | 41 | // NewTopic returns a Topic. 42 | func NewTopic(name string, client proto.TopicServiceClient) *Topic { 43 | return &Topic{ 44 | name: name, 45 | client: client, 46 | } 47 | } 48 | 49 | // Topic is a gRPC implementation of a topic. 50 | type Topic struct { 51 | name string 52 | client proto.TopicServiceClient 53 | } 54 | 55 | // Send a message to the topic. 56 | func (t *Topic) Send(message *lobby.Message) error { 57 | _, err := t.client.Send(context.Background(), &proto.NewMessage{ 58 | Topic: t.name, 59 | Message: &proto.Message{ 60 | Group: message.Group, 61 | Value: message.Value, 62 | }, 63 | }) 64 | 65 | return errFromGRPC(err) 66 | } 67 | 68 | // Close the topic session. 69 | func (t *Topic) Close() error { 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /rpc/backend_test.go: -------------------------------------------------------------------------------- 1 | package rpc_test 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "net" 7 | "os" 8 | "path" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "google.golang.org/grpc" 14 | 15 | "github.com/asdine/lobby" 16 | "github.com/asdine/lobby/log" 17 | "github.com/asdine/lobby/mock" 18 | "github.com/asdine/lobby/rpc" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | func newBackend(t *testing.T, b lobby.Backend) (*rpc.Backend, func()) { 24 | dir, err := ioutil.TempDir("", "lobby") 25 | require.NoError(t, err) 26 | 27 | socketPath := path.Join(dir, "lobby.sock") 28 | l, err := net.Listen("unix", socketPath) 29 | require.NoError(t, err) 30 | 31 | srv := rpc.NewServer(log.New(log.Output(ioutil.Discard)), rpc.WithTopicService(b)) 32 | 33 | var wg sync.WaitGroup 34 | wg.Add(1) 35 | go func() { 36 | defer wg.Done() 37 | srv.Serve(l) 38 | }() 39 | 40 | conn, err := grpc.Dial("", 41 | grpc.WithInsecure(), 42 | grpc.WithBlock(), 43 | grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { 44 | return net.DialTimeout("unix", socketPath, timeout) 45 | }), 46 | ) 47 | require.NoError(t, err) 48 | 49 | backend, err := rpc.NewBackend(conn) 50 | require.NoError(t, err) 51 | 52 | return backend, func() { 53 | err := conn.Close() 54 | require.NoError(t, err) 55 | 56 | srv.Stop() 57 | wg.Wait() 58 | os.RemoveAll(dir) 59 | } 60 | } 61 | 62 | func TestTopicSend(t *testing.T) { 63 | t.Run("OK", func(t *testing.T) { 64 | var b mock.Backend 65 | 66 | b.TopicFn = func(name string) (lobby.Topic, error) { 67 | require.Equal(t, "topic", name) 68 | 69 | return &mock.Topic{ 70 | SendFn: func(message *lobby.Message) error { 71 | assert.Equal(t, "group", message.Group) 72 | assert.Equal(t, []byte(`Value`), message.Value) 73 | return nil 74 | }, 75 | }, nil 76 | } 77 | 78 | backend, cleanup := newBackend(t, &b) 79 | defer cleanup() 80 | 81 | topic, err := backend.Topic("topic") 82 | require.NoError(t, err) 83 | 84 | err = topic.Send(&lobby.Message{ 85 | Group: "group", 86 | Value: []byte("Value"), 87 | }) 88 | require.NoError(t, err) 89 | }) 90 | 91 | t.Run("TopicNotFound", func(t *testing.T) { 92 | var b mock.Backend 93 | b.TopicFn = func(name string) (lobby.Topic, error) { 94 | assert.Equal(t, "unknown", name) 95 | return nil, lobby.ErrTopicNotFound 96 | } 97 | 98 | backend, cleanup := newBackend(t, &b) 99 | defer cleanup() 100 | 101 | topic, err := backend.Topic("unknown") 102 | require.NoError(t, err) 103 | 104 | err = topic.Send(&lobby.Message{ 105 | Group: "group", 106 | Value: []byte("Value"), 107 | }) 108 | require.Error(t, err) 109 | require.Equal(t, lobby.ErrTopicNotFound, err) 110 | }) 111 | 112 | t.Run("InternalError", func(t *testing.T) { 113 | var b mock.Backend 114 | 115 | b.TopicFn = func(name string) (lobby.Topic, error) { 116 | require.Equal(t, "topic", name) 117 | 118 | return &mock.Topic{ 119 | SendFn: func(message *lobby.Message) error { 120 | assert.Equal(t, "group", message.Group) 121 | assert.Equal(t, []byte(`Value`), message.Value) 122 | return errors.New("something unexpected happened !") 123 | }, 124 | }, nil 125 | } 126 | 127 | backend, cleanup := newBackend(t, &b) 128 | defer cleanup() 129 | 130 | topic, err := backend.Topic("topic") 131 | require.NoError(t, err) 132 | 133 | err = topic.Send(&lobby.Message{ 134 | Group: "group", 135 | Value: []byte("Value"), 136 | }) 137 | require.Error(t, err) 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /rpc/errors.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/log" 8 | "github.com/asdine/lobby/validation" 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | // HTTP errors 15 | const ( 16 | ErrInvalidJSON = lobby.Error("invalid_json") 17 | ErrInternal = lobby.Error("internal_error") 18 | ErrEmptyContent = lobby.Error("empty_content") 19 | ) 20 | 21 | // Error writes an API error message to the response and logger. 22 | func newError(err error, logger *log.Logger) error { 23 | var code codes.Code 24 | 25 | switch { 26 | case validation.IsError(err): 27 | code = codes.InvalidArgument 28 | case err == lobby.ErrTopicNotFound || err == lobby.ErrBackendNotFound: 29 | code = codes.NotFound 30 | case err == lobby.ErrTopicAlreadyExists: 31 | code = codes.AlreadyExists 32 | default: 33 | code = codes.Unknown 34 | } 35 | 36 | // Log error. 37 | logger.Debugf("grpc error: %s (code=%s)", err, code.String()) 38 | 39 | // Hide error from client if it is internal. 40 | if code == codes.Unknown { 41 | err = ErrInternal 42 | } 43 | 44 | return status.Error(code, err.Error()) 45 | } 46 | 47 | func errFromGRPC(err error) error { 48 | code := grpc.Code(err) 49 | 50 | switch code { 51 | case codes.AlreadyExists: 52 | return lobby.ErrTopicAlreadyExists 53 | case codes.NotFound: 54 | if strings.Contains(err.Error(), lobby.ErrBackendNotFound.Error()) { 55 | return lobby.ErrBackendNotFound 56 | } 57 | return lobby.ErrTopicNotFound 58 | default: 59 | return err 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /rpc/plugin.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "os" 8 | "os/exec" 9 | "path" 10 | "sync" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/asdine/lobby" 15 | "github.com/pkg/errors" 16 | "google.golang.org/grpc" 17 | ) 18 | 19 | var execCommand = exec.Command 20 | 21 | type process struct { 22 | *os.Process 23 | m sync.Mutex 24 | conn *grpc.ClientConn 25 | name string 26 | closed bool 27 | } 28 | 29 | func (p *process) Name() string { 30 | return p.name 31 | } 32 | 33 | func (p *process) Wait() error { 34 | if p.closed { 35 | return nil 36 | } 37 | 38 | status, err := p.Process.Wait() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | p.m.Lock() 44 | defer p.m.Unlock() 45 | 46 | if !p.closed { 47 | p.closed = true 48 | return fmt.Errorf("plugin %s exited unexpectedly", p.name) 49 | } 50 | 51 | if !status.Success() { 52 | return fmt.Errorf("plugin %s crashed during exit", p.name) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (p *process) Close() error { 59 | p.m.Lock() 60 | defer p.m.Unlock() 61 | 62 | if p.closed { 63 | return nil 64 | } 65 | 66 | p.closed = true 67 | 68 | if p.conn != nil { 69 | err := p.conn.Close() 70 | if err != nil { 71 | return err 72 | } 73 | p.conn = nil 74 | } 75 | 76 | return p.Signal(syscall.SIGTERM) 77 | } 78 | 79 | // LoadPlugin loads a plugin. 80 | func LoadPlugin(ctx context.Context, name, cmdPath, dataDir, configFile string) (lobby.Plugin, error) { 81 | select { 82 | case <-ctx.Done(): 83 | return nil, ctx.Err() 84 | default: 85 | } 86 | 87 | args := []string{ 88 | "--data-dir", dataDir, 89 | } 90 | 91 | if configFile != "" { 92 | args = append(args, "-c", configFile) 93 | } 94 | 95 | cmd := execCommand(cmdPath, args...) 96 | prefixFn := func() []byte { 97 | return []byte(time.Now().Format("2006/01/02 15:04:05") + " i | " + name + ": ") 98 | } 99 | 100 | cmd.Stdout = lobby.NewFuncPrefixWriter(prefixFn, os.Stdout) 101 | cmd.Stderr = lobby.NewFuncPrefixWriter(prefixFn, os.Stderr) 102 | cmd.SysProcAttr = &syscall.SysProcAttr{ 103 | Setpgid: true, 104 | Pgid: 0, 105 | } 106 | err := cmd.Start() 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | return &process{ 112 | Process: cmd.Process, 113 | name: name, 114 | }, nil 115 | } 116 | 117 | // LoadBackendPlugin loads a backend plugin. 118 | func LoadBackendPlugin(ctx context.Context, name, cmdPath, dataDir, configFile string) (lobby.Backend, lobby.Plugin, error) { 119 | plugin, err := LoadPlugin(ctx, name, cmdPath, dataDir, configFile) 120 | if err != nil { 121 | return nil, nil, err 122 | } 123 | 124 | socketPath := path.Join(dataDir, "sockets", fmt.Sprintf("%s.sock", name)) 125 | ticker := time.NewTicker(10 * time.Millisecond) 126 | defer ticker.Stop() 127 | 128 | Loop: 129 | for { 130 | select { 131 | case <-ticker.C: 132 | if _, err := os.Stat(socketPath); !os.IsNotExist(err) { 133 | break Loop 134 | } 135 | case <-ctx.Done(): 136 | err := plugin.Close() 137 | if err != nil { 138 | return nil, nil, errors.Wrapf(err, "failed to kill process %s", name) 139 | } 140 | 141 | return nil, nil, ctx.Err() 142 | } 143 | } 144 | 145 | conn, err := grpc.Dial("", 146 | grpc.WithInsecure(), 147 | grpc.WithBlock(), 148 | grpc.WithTimeout(1*time.Second), 149 | grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { 150 | return net.DialTimeout("unix", socketPath, timeout) 151 | }), 152 | ) 153 | if err != nil { 154 | return nil, nil, err 155 | } 156 | 157 | plugin.(*process).conn = conn 158 | bck, err := NewBackend(conn) 159 | if err != nil { 160 | return nil, nil, err 161 | } 162 | 163 | return bck, plugin, nil 164 | } 165 | -------------------------------------------------------------------------------- /rpc/plugin_test.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | "path" 12 | "syscall" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func setFakeCommand(t *testing.T, additionalArgs ...string) func() { 19 | execCommand = func(command string, args ...string) *exec.Cmd { 20 | cs := []string{"-test.run=TestHelperProcess", "--", command} 21 | cs = append(cs, args...) 22 | cs = append(cs, additionalArgs...) 23 | cmd := exec.Command(os.Args[0], cs...) 24 | cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} 25 | return cmd 26 | } 27 | 28 | return func() { 29 | execCommand = exec.Command 30 | } 31 | } 32 | 33 | func TestHelperProcess(t *testing.T) { 34 | if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { 35 | return 36 | } 37 | 38 | args := os.Args 39 | for len(args) > 0 { 40 | if args[0] == "--" { 41 | args = args[1:] 42 | break 43 | } 44 | args = args[1:] 45 | } 46 | if len(args) == 0 { 47 | fmt.Fprintf(os.Stderr, "No command\n") 48 | os.Exit(2) 49 | } 50 | 51 | cmd, args := args[0], args[1:] 52 | require.Equal(t, "/fake/command", cmd) 53 | require.Len(t, args, 2) 54 | require.Equal(t, "--data-dir", args[0]) 55 | l, err := net.Listen("unix", path.Join(args[1], "sockets", "backend.sock")) 56 | require.NoError(t, err) 57 | defer l.Close() 58 | 59 | c := make(chan os.Signal, 1) 60 | 61 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 62 | 63 | <-c 64 | os.Exit(0) 65 | } 66 | 67 | func TestLoadBackend(t *testing.T) { 68 | cleanup := setFakeCommand(t) 69 | defer cleanup() 70 | 71 | dir, err := ioutil.TempDir("", "lobby") 72 | require.NoError(t, err) 73 | defer os.RemoveAll(dir) 74 | err = os.Mkdir(path.Join(dir, "sockets"), 0755) 75 | require.NoError(t, err) 76 | 77 | bck, plg, err := LoadBackendPlugin(context.Background(), "backend", "/fake/command", dir, "") 78 | require.NoError(t, err) 79 | require.Equal(t, "backend", plg.Name()) 80 | err = bck.Close() 81 | require.NoError(t, err) 82 | err = plg.Close() 83 | require.NoError(t, err) 84 | } 85 | 86 | func TestLoadServer(t *testing.T) { 87 | cleanup := setFakeCommand(t) 88 | defer cleanup() 89 | 90 | dir, err := ioutil.TempDir("", "lobby") 91 | require.NoError(t, err) 92 | defer os.RemoveAll(dir) 93 | err = os.Mkdir(path.Join(dir, "sockets"), 0755) 94 | require.NoError(t, err) 95 | 96 | plg, err := LoadPlugin(context.Background(), "server", "/fake/command", dir, "") 97 | require.NoError(t, err) 98 | require.Equal(t, "server", plg.Name()) 99 | err = plg.Close() 100 | require.NoError(t, err) 101 | 102 | ctx, cancel := context.WithCancel(context.Background()) 103 | cancel() 104 | 105 | _, err = LoadPlugin(ctx, "server", "/fake/command", dir, "") 106 | require.Error(t, err) 107 | require.Equal(t, context.Canceled, err) 108 | } 109 | -------------------------------------------------------------------------------- /rpc/proto/proto.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | //go:generate protoc --go_out=plugins=grpc:. topic.proto registry.proto 4 | //go:generate protoc-go-inject-tag -input=./topic.pb.go 5 | //go:generate protoc-go-inject-tag -input=./registry.pb.go 6 | -------------------------------------------------------------------------------- /rpc/proto/registry.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. 2 | // source: registry.proto 3 | // DO NOT EDIT! 4 | 5 | package proto 6 | 7 | import proto1 "github.com/golang/protobuf/proto" 8 | import fmt "fmt" 9 | import math "math" 10 | 11 | import ( 12 | context "golang.org/x/net/context" 13 | grpc "google.golang.org/grpc" 14 | ) 15 | 16 | // Reference imports to suppress errors if they are not otherwise used. 17 | var _ = proto1.Marshal 18 | var _ = fmt.Errorf 19 | var _ = math.Inf 20 | 21 | type NewTopic struct { 22 | // Topic name. 23 | // @inject_tag: valid:"required" 24 | Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty" valid:"required"` 25 | // Backend used by this topic. 26 | // @inject_tag: valid:"required" 27 | Backend string `protobuf:"bytes,2,opt,name=backend" json:"backend,omitempty" valid:"required"` 28 | } 29 | 30 | func (m *NewTopic) Reset() { *m = NewTopic{} } 31 | func (m *NewTopic) String() string { return proto1.CompactTextString(m) } 32 | func (*NewTopic) ProtoMessage() {} 33 | func (*NewTopic) Descriptor() ([]byte, []int) { return fileDescriptor1, []int{0} } 34 | 35 | type Topic struct { 36 | // Topic name. 37 | // @inject_tag: valid:"required" 38 | Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty" valid:"required"` 39 | // Backend used by this topic. 40 | Backend string `protobuf:"bytes,2,opt,name=backend" json:"backend,omitempty"` 41 | } 42 | 43 | func (m *Topic) Reset() { *m = Topic{} } 44 | func (m *Topic) String() string { return proto1.CompactTextString(m) } 45 | func (*Topic) ProtoMessage() {} 46 | func (*Topic) Descriptor() ([]byte, []int) { return fileDescriptor1, []int{1} } 47 | 48 | type TopicStatus struct { 49 | Exists bool `protobuf:"varint,1,opt,name=exists" json:"exists,omitempty"` 50 | } 51 | 52 | func (m *TopicStatus) Reset() { *m = TopicStatus{} } 53 | func (m *TopicStatus) String() string { return proto1.CompactTextString(m) } 54 | func (*TopicStatus) ProtoMessage() {} 55 | func (*TopicStatus) Descriptor() ([]byte, []int) { return fileDescriptor1, []int{2} } 56 | 57 | func init() { 58 | proto1.RegisterType((*NewTopic)(nil), "proto.NewTopic") 59 | proto1.RegisterType((*Topic)(nil), "proto.Topic") 60 | proto1.RegisterType((*TopicStatus)(nil), "proto.TopicStatus") 61 | } 62 | 63 | // Reference imports to suppress errors if they are not otherwise used. 64 | var _ context.Context 65 | var _ grpc.ClientConn 66 | 67 | // This is a compile-time assertion to ensure that this generated file 68 | // is compatible with the grpc package it is being compiled against. 69 | const _ = grpc.SupportPackageIsVersion3 70 | 71 | // Client API for RegistryService service 72 | 73 | type RegistryServiceClient interface { 74 | Create(ctx context.Context, in *NewTopic, opts ...grpc.CallOption) (*Empty, error) 75 | Status(ctx context.Context, in *Topic, opts ...grpc.CallOption) (*TopicStatus, error) 76 | } 77 | 78 | type registryServiceClient struct { 79 | cc *grpc.ClientConn 80 | } 81 | 82 | func NewRegistryServiceClient(cc *grpc.ClientConn) RegistryServiceClient { 83 | return ®istryServiceClient{cc} 84 | } 85 | 86 | func (c *registryServiceClient) Create(ctx context.Context, in *NewTopic, opts ...grpc.CallOption) (*Empty, error) { 87 | out := new(Empty) 88 | err := grpc.Invoke(ctx, "/proto.RegistryService/Create", in, out, c.cc, opts...) 89 | if err != nil { 90 | return nil, err 91 | } 92 | return out, nil 93 | } 94 | 95 | func (c *registryServiceClient) Status(ctx context.Context, in *Topic, opts ...grpc.CallOption) (*TopicStatus, error) { 96 | out := new(TopicStatus) 97 | err := grpc.Invoke(ctx, "/proto.RegistryService/Status", in, out, c.cc, opts...) 98 | if err != nil { 99 | return nil, err 100 | } 101 | return out, nil 102 | } 103 | 104 | // Server API for RegistryService service 105 | 106 | type RegistryServiceServer interface { 107 | Create(context.Context, *NewTopic) (*Empty, error) 108 | Status(context.Context, *Topic) (*TopicStatus, error) 109 | } 110 | 111 | func RegisterRegistryServiceServer(s *grpc.Server, srv RegistryServiceServer) { 112 | s.RegisterService(&_RegistryService_serviceDesc, srv) 113 | } 114 | 115 | func _RegistryService_Create_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 116 | in := new(NewTopic) 117 | if err := dec(in); err != nil { 118 | return nil, err 119 | } 120 | if interceptor == nil { 121 | return srv.(RegistryServiceServer).Create(ctx, in) 122 | } 123 | info := &grpc.UnaryServerInfo{ 124 | Server: srv, 125 | FullMethod: "/proto.RegistryService/Create", 126 | } 127 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 128 | return srv.(RegistryServiceServer).Create(ctx, req.(*NewTopic)) 129 | } 130 | return interceptor(ctx, in, info, handler) 131 | } 132 | 133 | func _RegistryService_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 134 | in := new(Topic) 135 | if err := dec(in); err != nil { 136 | return nil, err 137 | } 138 | if interceptor == nil { 139 | return srv.(RegistryServiceServer).Status(ctx, in) 140 | } 141 | info := &grpc.UnaryServerInfo{ 142 | Server: srv, 143 | FullMethod: "/proto.RegistryService/Status", 144 | } 145 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 146 | return srv.(RegistryServiceServer).Status(ctx, req.(*Topic)) 147 | } 148 | return interceptor(ctx, in, info, handler) 149 | } 150 | 151 | var _RegistryService_serviceDesc = grpc.ServiceDesc{ 152 | ServiceName: "proto.RegistryService", 153 | HandlerType: (*RegistryServiceServer)(nil), 154 | Methods: []grpc.MethodDesc{ 155 | { 156 | MethodName: "Create", 157 | Handler: _RegistryService_Create_Handler, 158 | }, 159 | { 160 | MethodName: "Status", 161 | Handler: _RegistryService_Status_Handler, 162 | }, 163 | }, 164 | Streams: []grpc.StreamDesc{}, 165 | Metadata: fileDescriptor1, 166 | } 167 | 168 | func init() { proto1.RegisterFile("registry.proto", fileDescriptor1) } 169 | 170 | var fileDescriptor1 = []byte{ 171 | // 193 bytes of a gzipped FileDescriptorProto 172 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2b, 0x4a, 0x4d, 0xcf, 173 | 0x2c, 0x2e, 0x29, 0xaa, 0xd4, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x05, 0x53, 0x52, 0xdc, 174 | 0x25, 0xf9, 0x05, 0x99, 0xc9, 0x10, 0x31, 0x25, 0x0b, 0x2e, 0x0e, 0xbf, 0xd4, 0xf2, 0x10, 0x90, 175 | 0x88, 0x90, 0x10, 0x17, 0x4b, 0x5e, 0x62, 0x6e, 0xaa, 0x04, 0xa3, 0x02, 0xa3, 0x06, 0x67, 0x10, 176 | 0x98, 0x2d, 0x24, 0xc1, 0xc5, 0x9e, 0x94, 0x98, 0x9c, 0x9d, 0x9a, 0x97, 0x22, 0xc1, 0x04, 0x16, 177 | 0x86, 0x71, 0x95, 0x4c, 0xb9, 0x58, 0xc9, 0xd1, 0xa6, 0xca, 0xc5, 0x0d, 0xd6, 0x16, 0x5c, 0x92, 178 | 0x58, 0x52, 0x5a, 0x2c, 0x24, 0xc6, 0xc5, 0x96, 0x5a, 0x91, 0x59, 0x5c, 0x52, 0x0c, 0xd6, 0xce, 179 | 0x11, 0x04, 0xe5, 0x19, 0x65, 0x71, 0xf1, 0x07, 0x41, 0x5d, 0x1f, 0x9c, 0x5a, 0x54, 0x96, 0x99, 180 | 0x9c, 0x2a, 0xa4, 0xc9, 0xc5, 0xe6, 0x5c, 0x94, 0x9a, 0x58, 0x92, 0x2a, 0xc4, 0x0f, 0x71, 0xbc, 181 | 0x1e, 0xcc, 0xe5, 0x52, 0x3c, 0x50, 0x01, 0xd7, 0xdc, 0x82, 0x92, 0x4a, 0x25, 0x06, 0x21, 0x1d, 182 | 0x2e, 0x36, 0xa8, 0xf9, 0x30, 0x19, 0x88, 0x3a, 0x21, 0x64, 0x1e, 0x44, 0x85, 0x12, 0x43, 0x12, 183 | 0x1b, 0x58, 0xd0, 0x18, 0x10, 0x00, 0x00, 0xff, 0xff, 0x19, 0x75, 0xfd, 0xd9, 0x30, 0x01, 0x00, 184 | 0x00, 185 | } 186 | -------------------------------------------------------------------------------- /rpc/proto/registry.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package proto; 4 | 5 | import "topic.proto"; 6 | 7 | service RegistryService { 8 | rpc Create (NewTopic) returns (Empty) {} 9 | rpc Status (Topic) returns (TopicStatus) {} 10 | } 11 | 12 | message NewTopic { 13 | // Topic name. 14 | // @inject_tag: valid:"required" 15 | string name = 1; 16 | 17 | // Backend used by this topic. 18 | // @inject_tag: valid:"required" 19 | string backend = 2; 20 | } 21 | 22 | message Topic { 23 | // Topic name. 24 | // @inject_tag: valid:"required" 25 | string name = 1; 26 | 27 | // Backend used by this topic. 28 | string backend = 2; 29 | } 30 | 31 | message TopicStatus { 32 | bool exists = 1; 33 | } 34 | -------------------------------------------------------------------------------- /rpc/proto/topic.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. 2 | // source: topic.proto 3 | // DO NOT EDIT! 4 | 5 | /* 6 | Package proto is a generated protocol buffer package. 7 | 8 | It is generated from these files: 9 | topic.proto 10 | registry.proto 11 | 12 | It has these top-level messages: 13 | Empty 14 | NewMessage 15 | Message 16 | NewTopic 17 | Topic 18 | TopicStatus 19 | */ 20 | package proto 21 | 22 | import proto1 "github.com/golang/protobuf/proto" 23 | import fmt "fmt" 24 | import math "math" 25 | 26 | import ( 27 | context "golang.org/x/net/context" 28 | grpc "google.golang.org/grpc" 29 | ) 30 | 31 | // Reference imports to suppress errors if they are not otherwise used. 32 | var _ = proto1.Marshal 33 | var _ = fmt.Errorf 34 | var _ = math.Inf 35 | 36 | // This is a compile-time assertion to ensure that this generated file 37 | // is compatible with the proto package it is being compiled against. 38 | // A compilation error at this line likely means your copy of the 39 | // proto package needs to be updated. 40 | const _ = proto1.ProtoPackageIsVersion2 // please upgrade the proto package 41 | 42 | // Empty response. 43 | type Empty struct { 44 | } 45 | 46 | func (m *Empty) Reset() { *m = Empty{} } 47 | func (m *Empty) String() string { return proto1.CompactTextString(m) } 48 | func (*Empty) ProtoMessage() {} 49 | func (*Empty) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } 50 | 51 | // NewMessage is used to put an item in a topic. 52 | type NewMessage struct { 53 | // Topic name. 54 | // @inject_tag: valid:"required" 55 | Topic string `protobuf:"bytes,1,opt,name=topic" json:"topic,omitempty" valid:"required"` 56 | // Message to send to the topic. 57 | // @inject_tag: valid:"required" 58 | Message *Message `protobuf:"bytes,2,opt,name=message" json:"message,omitempty" valid:"required"` 59 | } 60 | 61 | func (m *NewMessage) Reset() { *m = NewMessage{} } 62 | func (m *NewMessage) String() string { return proto1.CompactTextString(m) } 63 | func (*NewMessage) ProtoMessage() {} 64 | func (*NewMessage) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{1} } 65 | 66 | func (m *NewMessage) GetMessage() *Message { 67 | if m != nil { 68 | return m.Message 69 | } 70 | return nil 71 | } 72 | 73 | type Message struct { 74 | Group string `protobuf:"bytes,1,opt,name=group" json:"group,omitempty"` 75 | // @inject_tag: valid:"required" 76 | Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty" valid:"required"` 77 | } 78 | 79 | func (m *Message) Reset() { *m = Message{} } 80 | func (m *Message) String() string { return proto1.CompactTextString(m) } 81 | func (*Message) ProtoMessage() {} 82 | func (*Message) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{2} } 83 | 84 | func init() { 85 | proto1.RegisterType((*Empty)(nil), "proto.Empty") 86 | proto1.RegisterType((*NewMessage)(nil), "proto.NewMessage") 87 | proto1.RegisterType((*Message)(nil), "proto.Message") 88 | } 89 | 90 | // Reference imports to suppress errors if they are not otherwise used. 91 | var _ context.Context 92 | var _ grpc.ClientConn 93 | 94 | // This is a compile-time assertion to ensure that this generated file 95 | // is compatible with the grpc package it is being compiled against. 96 | const _ = grpc.SupportPackageIsVersion3 97 | 98 | // Client API for TopicService service 99 | 100 | type TopicServiceClient interface { 101 | // Send message to the topic. 102 | Send(ctx context.Context, in *NewMessage, opts ...grpc.CallOption) (*Empty, error) 103 | } 104 | 105 | type topicServiceClient struct { 106 | cc *grpc.ClientConn 107 | } 108 | 109 | func NewTopicServiceClient(cc *grpc.ClientConn) TopicServiceClient { 110 | return &topicServiceClient{cc} 111 | } 112 | 113 | func (c *topicServiceClient) Send(ctx context.Context, in *NewMessage, opts ...grpc.CallOption) (*Empty, error) { 114 | out := new(Empty) 115 | err := grpc.Invoke(ctx, "/proto.TopicService/Send", in, out, c.cc, opts...) 116 | if err != nil { 117 | return nil, err 118 | } 119 | return out, nil 120 | } 121 | 122 | // Server API for TopicService service 123 | 124 | type TopicServiceServer interface { 125 | // Send message to the topic. 126 | Send(context.Context, *NewMessage) (*Empty, error) 127 | } 128 | 129 | func RegisterTopicServiceServer(s *grpc.Server, srv TopicServiceServer) { 130 | s.RegisterService(&_TopicService_serviceDesc, srv) 131 | } 132 | 133 | func _TopicService_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 134 | in := new(NewMessage) 135 | if err := dec(in); err != nil { 136 | return nil, err 137 | } 138 | if interceptor == nil { 139 | return srv.(TopicServiceServer).Send(ctx, in) 140 | } 141 | info := &grpc.UnaryServerInfo{ 142 | Server: srv, 143 | FullMethod: "/proto.TopicService/Send", 144 | } 145 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 146 | return srv.(TopicServiceServer).Send(ctx, req.(*NewMessage)) 147 | } 148 | return interceptor(ctx, in, info, handler) 149 | } 150 | 151 | var _TopicService_serviceDesc = grpc.ServiceDesc{ 152 | ServiceName: "proto.TopicService", 153 | HandlerType: (*TopicServiceServer)(nil), 154 | Methods: []grpc.MethodDesc{ 155 | { 156 | MethodName: "Send", 157 | Handler: _TopicService_Send_Handler, 158 | }, 159 | }, 160 | Streams: []grpc.StreamDesc{}, 161 | Metadata: fileDescriptor0, 162 | } 163 | 164 | func init() { proto1.RegisterFile("topic.proto", fileDescriptor0) } 165 | 166 | var fileDescriptor0 = []byte{ 167 | // 169 bytes of a gzipped FileDescriptorProto 168 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2e, 0xc9, 0x2f, 0xc8, 169 | 0x4c, 0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x05, 0x53, 0x4a, 0xec, 0x5c, 0xac, 0xae, 170 | 0xb9, 0x05, 0x25, 0x95, 0x4a, 0x3e, 0x5c, 0x5c, 0x7e, 0xa9, 0xe5, 0xbe, 0xa9, 0xc5, 0xc5, 0x89, 171 | 0xe9, 0xa9, 0x42, 0x22, 0x5c, 0xac, 0x60, 0xc5, 0x12, 0x8c, 0x0a, 0x8c, 0x1a, 0x9c, 0x41, 0x10, 172 | 0x8e, 0x90, 0x06, 0x17, 0x7b, 0x2e, 0x44, 0x81, 0x04, 0x93, 0x02, 0xa3, 0x06, 0xb7, 0x11, 0x1f, 173 | 0xc4, 0x30, 0x3d, 0xa8, 0xb6, 0x20, 0x98, 0xb4, 0x92, 0x29, 0x17, 0x3b, 0x92, 0x51, 0xe9, 0x45, 174 | 0xf9, 0xa5, 0x05, 0x30, 0xa3, 0xc0, 0x1c, 0x90, 0x68, 0x59, 0x62, 0x4e, 0x29, 0xc4, 0x20, 0x9e, 175 | 0x20, 0x08, 0xc7, 0xc8, 0x92, 0x8b, 0x27, 0x04, 0x64, 0x53, 0x70, 0x6a, 0x51, 0x59, 0x66, 0x72, 176 | 0xaa, 0x90, 0x26, 0x17, 0x4b, 0x70, 0x6a, 0x5e, 0x8a, 0x90, 0x20, 0xd4, 0x1e, 0x84, 0x0b, 0xa5, 177 | 0x78, 0xa0, 0x42, 0x10, 0xd7, 0x33, 0x24, 0xb1, 0x81, 0xb9, 0xc6, 0x80, 0x00, 0x00, 0x00, 0xff, 178 | 0xff, 0x93, 0x32, 0x15, 0x65, 0xe5, 0x00, 0x00, 0x00, 179 | } 180 | -------------------------------------------------------------------------------- /rpc/proto/topic.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package proto; 4 | 5 | // Empty response. 6 | message Empty {} 7 | 8 | // The Topic service definition. 9 | service TopicService { 10 | // Send message to the topic. 11 | rpc Send (NewMessage) returns (Empty) {} 12 | } 13 | 14 | // NewMessage is used to put an item in a topic. 15 | message NewMessage { 16 | // Topic name. 17 | // @inject_tag: valid:"required" 18 | string topic = 1; 19 | 20 | // Message to send to the topic. 21 | // @inject_tag: valid:"required" 22 | Message message = 2; 23 | } 24 | 25 | message Message { 26 | string group = 1; 27 | // @inject_tag: valid:"required" 28 | bytes value = 2; 29 | } 30 | -------------------------------------------------------------------------------- /rpc/registry.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/log" 8 | "github.com/asdine/lobby/rpc/proto" 9 | "github.com/asdine/lobby/validation" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | func newRegistryService(r lobby.Registry, logger *log.Logger) *registryService { 14 | return ®istryService{ 15 | registry: r, 16 | logger: logger, 17 | } 18 | } 19 | 20 | type registryService struct { 21 | registry lobby.Registry 22 | logger *log.Logger 23 | } 24 | 25 | // Create a topic in the registry. 26 | func (s *registryService) Create(ctx context.Context, newTopic *proto.NewTopic) (*proto.Empty, error) { 27 | err := validation.Validate(newTopic) 28 | if err != nil { 29 | return nil, newError(err, s.logger) 30 | } 31 | 32 | err = s.registry.Create(newTopic.Backend, newTopic.Name) 33 | if err != nil { 34 | return nil, newError(err, s.logger) 35 | } 36 | 37 | return new(proto.Empty), nil 38 | } 39 | 40 | // Exists check a topic in the registry. 41 | func (s *registryService) Status(ctx context.Context, topic *proto.Topic) (*proto.TopicStatus, error) { 42 | err := validation.Validate(topic) 43 | if err != nil { 44 | return nil, newError(err, s.logger) 45 | } 46 | 47 | var exists bool 48 | 49 | _, err = s.registry.Topic(topic.Name) 50 | if err != nil { 51 | if err != lobby.ErrTopicNotFound { 52 | return nil, newError(err, s.logger) 53 | } 54 | } else { 55 | exists = true 56 | } 57 | 58 | return &proto.TopicStatus{ 59 | Exists: exists, 60 | }, nil 61 | } 62 | 63 | var _ lobby.Registry = new(Registry) 64 | 65 | // NewRegistry returns a gRPC Registry. It is used to communicate with external Registries. 66 | func NewRegistry(conn *grpc.ClientConn) (*Registry, error) { 67 | client := proto.NewRegistryServiceClient(conn) 68 | 69 | backend, err := NewBackend(conn) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return &Registry{ 75 | Backend: backend, 76 | conn: conn, 77 | client: client, 78 | }, nil 79 | } 80 | 81 | // Registry is a gRPC Registry. 82 | type Registry struct { 83 | lobby.Backend 84 | 85 | conn *grpc.ClientConn 86 | client proto.RegistryServiceClient 87 | } 88 | 89 | // RegisterBackend should never be called on this type. 90 | func (s *Registry) RegisterBackend(_ string, _ lobby.Backend) { 91 | panic("RegisterBackend should not be called on this type") 92 | } 93 | 94 | // Create a topic and register it to the Registry. 95 | func (s *Registry) Create(backendName, topicName string) error { 96 | _, err := s.client.Create(context.Background(), &proto.NewTopic{Name: topicName, Backend: backendName}) 97 | return errFromGRPC(err) 98 | } 99 | 100 | // Topic returns the topic associated with the given id. 101 | func (s *Registry) Topic(name string) (lobby.Topic, error) { 102 | status, err := s.client.Status(context.Background(), &proto.Topic{Name: name}) 103 | if err != nil { 104 | return nil, errFromGRPC(err) 105 | } 106 | 107 | if !status.Exists { 108 | return nil, lobby.ErrTopicNotFound 109 | } 110 | 111 | return s.Backend.Topic(name) 112 | } 113 | 114 | // Close the connexion to the Registry. 115 | func (s *Registry) Close() error { 116 | return s.conn.Close() 117 | } 118 | -------------------------------------------------------------------------------- /rpc/registry_test.go: -------------------------------------------------------------------------------- 1 | package rpc_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/ioutil" 7 | "net" 8 | "os" 9 | "path" 10 | "testing" 11 | "time" 12 | 13 | "google.golang.org/grpc" 14 | "google.golang.org/grpc/codes" 15 | "google.golang.org/grpc/status" 16 | 17 | "github.com/asdine/lobby" 18 | "github.com/asdine/lobby/log" 19 | "github.com/asdine/lobby/mock" 20 | "github.com/asdine/lobby/rpc" 21 | "github.com/asdine/lobby/rpc/proto" 22 | "github.com/stretchr/testify/assert" 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func TestRegistryServerCreate(t *testing.T) { 27 | t.Run("OK", func(t *testing.T) { 28 | var r mock.Registry 29 | 30 | r.CreateFn = func(backendName, topicName string) error { 31 | assert.Equal(t, "backend", backendName) 32 | assert.Equal(t, "topic", topicName) 33 | 34 | return nil 35 | } 36 | 37 | conn, cleanup := newServer(t, &r) 38 | defer cleanup() 39 | 40 | client := proto.NewRegistryServiceClient(conn) 41 | 42 | _, err := client.Create(context.Background(), &proto.NewTopic{Name: "topic", Backend: "backend"}) 43 | require.NoError(t, err) 44 | }) 45 | 46 | t.Run("EmptyFields", func(t *testing.T) { 47 | var r mock.Registry 48 | conn, cleanup := newServer(t, &r) 49 | defer cleanup() 50 | client := proto.NewRegistryServiceClient(conn) 51 | 52 | _, err := client.Create(context.Background(), new(proto.NewTopic)) 53 | require.Error(t, err) 54 | require.Equal(t, codes.InvalidArgument, grpc.Code(err)) 55 | }) 56 | 57 | t.Run("TopicAlreadyExists", func(t *testing.T) { 58 | var r mock.Registry 59 | 60 | r.CreateFn = func(backendName, topicName string) error { 61 | assert.Equal(t, "backend", backendName) 62 | assert.Equal(t, "topic", topicName) 63 | 64 | return lobby.ErrTopicAlreadyExists 65 | } 66 | 67 | conn, cleanup := newServer(t, &r) 68 | defer cleanup() 69 | 70 | client := proto.NewRegistryServiceClient(conn) 71 | 72 | _, err := client.Create(context.Background(), &proto.NewTopic{Name: "topic", Backend: "backend"}) 73 | require.Error(t, err) 74 | require.Equal(t, codes.AlreadyExists, grpc.Code(err)) 75 | }) 76 | 77 | t.Run("BackendNotFound", func(t *testing.T) { 78 | var r mock.Registry 79 | 80 | r.CreateFn = func(backendName, topicName string) error { 81 | assert.Equal(t, "backend", backendName) 82 | assert.Equal(t, "topic", topicName) 83 | 84 | return lobby.ErrBackendNotFound 85 | } 86 | 87 | conn, cleanup := newServer(t, &r) 88 | defer cleanup() 89 | 90 | client := proto.NewRegistryServiceClient(conn) 91 | 92 | _, err := client.Create(context.Background(), &proto.NewTopic{Name: "topic", Backend: "backend"}) 93 | require.Error(t, err) 94 | require.Equal(t, codes.NotFound, grpc.Code(err)) 95 | }) 96 | 97 | t.Run("InternalError", func(t *testing.T) { 98 | var r mock.Registry 99 | 100 | r.CreateFn = func(backendName, topicName string) error { 101 | assert.Equal(t, "backend", backendName) 102 | assert.Equal(t, "topic", topicName) 103 | 104 | return errors.New("something unexpected happened !") 105 | } 106 | 107 | conn, cleanup := newServer(t, &r) 108 | defer cleanup() 109 | 110 | client := proto.NewRegistryServiceClient(conn) 111 | 112 | _, err := client.Create(context.Background(), &proto.NewTopic{Name: "topic", Backend: "backend"}) 113 | require.Error(t, err) 114 | require.Equal(t, codes.Unknown, grpc.Code(err)) 115 | }) 116 | } 117 | 118 | func TestRegistryServerStatus(t *testing.T) { 119 | t.Run("OK", func(t *testing.T) { 120 | var r mock.Registry 121 | 122 | r.TopicFn = func(name string) (lobby.Topic, error) { 123 | assert.Equal(t, "topic", name) 124 | 125 | return new(mock.Topic), nil 126 | } 127 | 128 | conn, cleanup := newServer(t, &r) 129 | defer cleanup() 130 | 131 | client := proto.NewRegistryServiceClient(conn) 132 | 133 | status, err := client.Status(context.Background(), &proto.Topic{Name: "topic"}) 134 | require.NoError(t, err) 135 | require.True(t, status.Exists) 136 | }) 137 | 138 | t.Run("EmptyFields", func(t *testing.T) { 139 | var r mock.Registry 140 | conn, cleanup := newServer(t, &r) 141 | defer cleanup() 142 | client := proto.NewRegistryServiceClient(conn) 143 | 144 | _, err := client.Status(context.Background(), new(proto.Topic)) 145 | require.Error(t, err) 146 | require.Equal(t, codes.InvalidArgument, grpc.Code(err)) 147 | }) 148 | 149 | t.Run("NotFound", func(t *testing.T) { 150 | var r mock.Registry 151 | 152 | r.TopicFn = func(name string) (lobby.Topic, error) { 153 | assert.Equal(t, "topic", name) 154 | 155 | return nil, lobby.ErrTopicNotFound 156 | } 157 | 158 | conn, cleanup := newServer(t, &r) 159 | defer cleanup() 160 | 161 | client := proto.NewRegistryServiceClient(conn) 162 | 163 | status, err := client.Status(context.Background(), &proto.Topic{Name: "topic"}) 164 | require.NoError(t, err) 165 | require.False(t, status.Exists) 166 | }) 167 | 168 | t.Run("InternalError", func(t *testing.T) { 169 | var r mock.Registry 170 | 171 | r.TopicFn = func(name string) (lobby.Topic, error) { 172 | assert.Equal(t, "topic", name) 173 | 174 | return nil, errors.New("something unexpected happened !") 175 | } 176 | 177 | conn, cleanup := newServer(t, &r) 178 | defer cleanup() 179 | 180 | client := proto.NewRegistryServiceClient(conn) 181 | 182 | _, err := client.Status(context.Background(), &proto.Topic{Name: "topic"}) 183 | require.Error(t, err) 184 | require.Equal(t, codes.Unknown, grpc.Code(err)) 185 | }) 186 | } 187 | 188 | func newRegistry(t *testing.T, r lobby.Registry) (*rpc.Registry, func()) { 189 | dir, err := ioutil.TempDir("", "lobby") 190 | require.NoError(t, err) 191 | 192 | socketPath := path.Join(dir, "lobby.sock") 193 | l, err := net.Listen("unix", socketPath) 194 | require.NoError(t, err) 195 | 196 | srv := rpc.NewServer(log.New(log.Output(ioutil.Discard)), rpc.WithTopicService(r), rpc.WithRegistryService(r)) 197 | 198 | go func() { 199 | srv.Serve(l) 200 | }() 201 | 202 | conn, err := grpc.Dial("", 203 | grpc.WithInsecure(), 204 | grpc.WithBlock(), 205 | grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { 206 | return net.DialTimeout("unix", socketPath, timeout) 207 | }), 208 | ) 209 | require.NoError(t, err) 210 | 211 | reg, err := rpc.NewRegistry(conn) 212 | require.NoError(t, err) 213 | reg.Backend = new(mock.Backend) 214 | 215 | return reg, func() { 216 | reg.Close() 217 | srv.Stop() 218 | os.RemoveAll(dir) 219 | } 220 | } 221 | 222 | func TestRegistryCreate(t *testing.T) { 223 | t.Run("OK", func(t *testing.T) { 224 | var r mock.Registry 225 | 226 | r.CreateFn = func(backendName, topicName string) error { 227 | assert.Equal(t, "backend", backendName) 228 | assert.Equal(t, "topic", topicName) 229 | 230 | return nil 231 | } 232 | 233 | reg, cleanup := newRegistry(t, &r) 234 | defer cleanup() 235 | 236 | err := reg.Create("backend", "topic") 237 | require.NoError(t, err) 238 | }) 239 | 240 | t.Run("Errors", func(t *testing.T) { 241 | var r mock.Registry 242 | 243 | reg, cleanup := newRegistry(t, &r) 244 | defer cleanup() 245 | 246 | testCases := map[error]error{ 247 | lobby.ErrTopicAlreadyExists: lobby.ErrTopicAlreadyExists, 248 | lobby.ErrBackendNotFound: lobby.ErrBackendNotFound, 249 | lobby.ErrTopicNotFound: lobby.ErrTopicNotFound, 250 | errors.New("unexpected"): status.Error(codes.Unknown, rpc.ErrInternal.Error()), 251 | } 252 | 253 | for returnedErr, expectedErr := range testCases { 254 | testRegistryCreateWith(t, reg, &r, returnedErr, expectedErr) 255 | } 256 | }) 257 | } 258 | 259 | func testRegistryCreateWith(t *testing.T, reg lobby.Registry, mockReg *mock.Registry, returnedErr, expectedErr error) { 260 | mockReg.CreateFn = func(backendName, topicName string) error { 261 | return returnedErr 262 | } 263 | 264 | err := reg.Create("backend", "topic") 265 | require.Error(t, err) 266 | require.Equal(t, expectedErr, err) 267 | } 268 | 269 | func TestRegistryTopic(t *testing.T) { 270 | t.Run("OK", func(t *testing.T) { 271 | var r mock.Registry 272 | 273 | r.TopicFn = func(name string) (lobby.Topic, error) { 274 | assert.Equal(t, "topic", name) 275 | 276 | return nil, nil 277 | } 278 | 279 | reg, cleanup := newRegistry(t, &r) 280 | defer cleanup() 281 | 282 | _, err := reg.Topic("topic") 283 | require.NoError(t, err) 284 | require.Equal(t, 1, reg.Backend.(*mock.Backend).TopicInvoked) 285 | }) 286 | 287 | t.Run("Errors", func(t *testing.T) { 288 | var r mock.Registry 289 | 290 | reg, cleanup := newRegistry(t, &r) 291 | defer cleanup() 292 | 293 | testCases := map[error]error{ 294 | lobby.ErrTopicAlreadyExists: lobby.ErrTopicAlreadyExists, 295 | lobby.ErrBackendNotFound: lobby.ErrBackendNotFound, 296 | lobby.ErrTopicNotFound: lobby.ErrTopicNotFound, 297 | errors.New("unexpected"): status.Error(codes.Unknown, rpc.ErrInternal.Error()), 298 | } 299 | 300 | for returnedErr, expectedErr := range testCases { 301 | testRegistryTopicWith(t, reg, &r, returnedErr, expectedErr) 302 | } 303 | }) 304 | } 305 | 306 | func testRegistryTopicWith(t *testing.T, reg lobby.Registry, mockReg *mock.Registry, returnedErr, expectedErr error) { 307 | mockReg.TopicFn = func(name string) (lobby.Topic, error) { 308 | return nil, returnedErr 309 | } 310 | 311 | _, err := reg.Topic("topic") 312 | require.Error(t, err) 313 | require.Equal(t, expectedErr, err) 314 | } 315 | -------------------------------------------------------------------------------- /rpc/server.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/log" 8 | "github.com/asdine/lobby/rpc/proto" 9 | "github.com/grpc-ecosystem/go-grpc-middleware" 10 | "github.com/grpc-ecosystem/go-grpc-middleware/recovery" 11 | "google.golang.org/grpc" 12 | ) 13 | 14 | // NewServer returns a configured gRPC server. 15 | func NewServer(logger *log.Logger, services ...func(*grpc.Server, *log.Logger)) lobby.Server { 16 | g := grpc.NewServer( 17 | grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer( 18 | grpc_recovery.UnaryServerInterceptor(), 19 | )), 20 | ) 21 | 22 | for _, s := range services { 23 | s(g, logger) 24 | } 25 | 26 | return &server{srv: g} 27 | } 28 | 29 | // WithTopicService enables the TopicService. 30 | func WithTopicService(b lobby.Backend) func(*grpc.Server, *log.Logger) { 31 | return func(g *grpc.Server, logger *log.Logger) { 32 | proto.RegisterTopicServiceServer(g, newTopicService(b, logger)) 33 | } 34 | } 35 | 36 | // WithRegistryService enables the RegistryService. 37 | func WithRegistryService(r lobby.Registry) func(*grpc.Server, *log.Logger) { 38 | return func(g *grpc.Server, logger *log.Logger) { 39 | proto.RegisterRegistryServiceServer(g, newRegistryService(r, logger)) 40 | } 41 | } 42 | 43 | type server struct { 44 | srv *grpc.Server 45 | } 46 | 47 | func (s *server) Name() string { 48 | return "gRPC" 49 | } 50 | 51 | func (s *server) Serve(l net.Listener) error { 52 | return s.srv.Serve(l) 53 | } 54 | 55 | func (s *server) Stop() error { 56 | s.srv.GracefulStop() 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /rpc/server_test.go: -------------------------------------------------------------------------------- 1 | package rpc_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "net" 6 | "os" 7 | "path" 8 | "testing" 9 | "time" 10 | 11 | "github.com/asdine/lobby" 12 | "github.com/asdine/lobby/log" 13 | "github.com/asdine/lobby/rpc" 14 | "github.com/stretchr/testify/require" 15 | "google.golang.org/grpc" 16 | ) 17 | 18 | func newServer(t *testing.T, r lobby.Registry) (*grpc.ClientConn, func()) { 19 | dir, err := ioutil.TempDir("", "lobby") 20 | require.NoError(t, err) 21 | 22 | socketPath := path.Join(dir, "lobby.sock") 23 | l, err := net.Listen("unix", socketPath) 24 | require.NoError(t, err) 25 | 26 | srv := rpc.NewServer(log.New(log.Output(ioutil.Discard)), rpc.WithTopicService(r), rpc.WithRegistryService(r)) 27 | 28 | go func() { 29 | srv.Serve(l) 30 | }() 31 | 32 | conn, err := grpc.Dial("", 33 | grpc.WithInsecure(), 34 | grpc.WithBlock(), 35 | grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { 36 | return net.DialTimeout("unix", socketPath, timeout) 37 | }), 38 | ) 39 | require.NoError(t, err) 40 | 41 | return conn, func() { 42 | conn.Close() 43 | srv.Stop() 44 | os.RemoveAll(dir) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /rpc/topic.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asdine/lobby" 7 | "github.com/asdine/lobby/log" 8 | "github.com/asdine/lobby/rpc/proto" 9 | "github.com/asdine/lobby/validation" 10 | ) 11 | 12 | func newTopicService(b lobby.Backend, logger *log.Logger) *topicService { 13 | return &topicService{ 14 | backend: b, 15 | logger: logger, 16 | } 17 | } 18 | 19 | type topicService struct { 20 | backend lobby.Backend 21 | logger *log.Logger 22 | } 23 | 24 | // Send an message to a topic. 25 | func (s *topicService) Send(ctx context.Context, message *proto.NewMessage) (*proto.Empty, error) { 26 | err := validation.Validate(message) 27 | if err != nil { 28 | return nil, newError(err, s.logger) 29 | } 30 | 31 | t, err := s.backend.Topic(message.Topic) 32 | if err != nil { 33 | return nil, newError(err, s.logger) 34 | } 35 | 36 | err = t.Send(&lobby.Message{ 37 | Group: message.Message.Group, 38 | Value: message.Message.Value, 39 | }) 40 | if err != nil { 41 | return nil, newError(err, s.logger) 42 | } 43 | 44 | return new(proto.Empty), nil 45 | } 46 | -------------------------------------------------------------------------------- /rpc/topic_test.go: -------------------------------------------------------------------------------- 1 | package rpc_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/asdine/lobby" 9 | "github.com/asdine/lobby/mock" 10 | "github.com/asdine/lobby/rpc/proto" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "google.golang.org/grpc" 14 | "google.golang.org/grpc/codes" 15 | ) 16 | 17 | func TestTopicServerSend(t *testing.T) { 18 | t.Run("OK", func(t *testing.T) { 19 | var r mock.Registry 20 | 21 | r.TopicFn = func(name string) (lobby.Topic, error) { 22 | assert.Equal(t, "topic", name) 23 | 24 | return &mock.Topic{ 25 | SendFn: func(message *lobby.Message) error { 26 | assert.Equal(t, "group", message.Group) 27 | assert.Equal(t, "value", string(message.Value)) 28 | return nil 29 | }, 30 | }, nil 31 | } 32 | 33 | conn, cleanup := newServer(t, &r) 34 | defer cleanup() 35 | 36 | client := proto.NewTopicServiceClient(conn) 37 | 38 | _, err := client.Send(context.Background(), &proto.NewMessage{ 39 | Message: &proto.Message{ 40 | Group: "group", 41 | Value: []byte("value"), 42 | }, 43 | Topic: "topic", 44 | }) 45 | require.NoError(t, err) 46 | }) 47 | 48 | t.Run("EmptyFields", func(t *testing.T) { 49 | var r mock.Registry 50 | conn, cleanup := newServer(t, &r) 51 | defer cleanup() 52 | client := proto.NewTopicServiceClient(conn) 53 | 54 | _, err := client.Send(context.Background(), new(proto.NewMessage)) 55 | require.Error(t, err) 56 | require.Equal(t, codes.InvalidArgument, grpc.Code(err)) 57 | }) 58 | 59 | t.Run("TopicNotFound", func(t *testing.T) { 60 | var r mock.Registry 61 | r.TopicFn = func(name string) (lobby.Topic, error) { 62 | assert.Equal(t, "unknown", name) 63 | return nil, lobby.ErrTopicNotFound 64 | } 65 | 66 | conn, cleanup := newServer(t, &r) 67 | defer cleanup() 68 | client := proto.NewTopicServiceClient(conn) 69 | 70 | _, err := client.Send(context.Background(), &proto.NewMessage{ 71 | Message: &proto.Message{ 72 | Group: "group", 73 | Value: []byte("value"), 74 | }, 75 | Topic: "unknown", 76 | }) 77 | require.Error(t, err) 78 | require.Equal(t, codes.NotFound, grpc.Code(err)) 79 | }) 80 | 81 | t.Run("InternalError", func(t *testing.T) { 82 | var r mock.Registry 83 | r.TopicFn = func(name string) (lobby.Topic, error) { 84 | assert.Equal(t, "topic", name) 85 | 86 | return &mock.Topic{ 87 | SendFn: func(message *lobby.Message) error { 88 | assert.Equal(t, "group", message.Group) 89 | assert.Equal(t, "value", string(message.Value)) 90 | return errors.New("something unexpected happened !") 91 | }, 92 | }, nil 93 | } 94 | 95 | conn, cleanup := newServer(t, &r) 96 | defer cleanup() 97 | client := proto.NewTopicServiceClient(conn) 98 | 99 | _, err := client.Send(context.Background(), &proto.NewMessage{ 100 | Message: &proto.Message{ 101 | Group: "group", 102 | Value: []byte("value"), 103 | }, 104 | Topic: "topic", 105 | }) 106 | require.Error(t, err) 107 | require.Equal(t, codes.Unknown, grpc.Code(err)) 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package lobby 2 | 3 | import "net" 4 | 5 | // A Server serves incoming requests. 6 | type Server interface { 7 | // Name returns the server short description. e.g: "http", "grpc", etc. 8 | Name() string 9 | 10 | // Serve incoming requests. Must block. 11 | Serve(net.Listener) error 12 | 13 | // Stop gracefully stops the server. 14 | Stop() error 15 | } 16 | -------------------------------------------------------------------------------- /topic.go: -------------------------------------------------------------------------------- 1 | package lobby 2 | 3 | // Errors. 4 | const ( 5 | ErrBackendNotFound = Error("backend not found") 6 | ErrTopicNotFound = Error("topic not found") 7 | ErrTopicAlreadyExists = Error("topic already exists") 8 | ) 9 | 10 | // A Message is a key value pair saved in a topic. 11 | type Message struct { 12 | Group string 13 | Value []byte 14 | } 15 | 16 | // A Topic manages a collection of items. 17 | type Topic interface { 18 | // Send a message in the topic. 19 | Send(*Message) error 20 | // Close the topic. Can be used to close sessions if required. 21 | Close() error 22 | } 23 | 24 | // TopicFunc creates a topic from a send function. 25 | func TopicFunc(fn func(*Message) error) Topic { 26 | return &topicFunc{fn} 27 | } 28 | 29 | type topicFunc struct { 30 | fn func(*Message) error 31 | } 32 | 33 | func (t *topicFunc) Send(m *Message) error { 34 | return t.fn(m) 35 | } 36 | 37 | func (t *topicFunc) Close() error { 38 | return nil 39 | } 40 | 41 | // A Backend is able to create topics that can be used to store data. 42 | type Backend interface { 43 | // Get a topic by name. 44 | Topic(name string) (Topic, error) 45 | // Close the backend connection. 46 | Close() error 47 | } 48 | 49 | // A Registry manages the topics, their configuration and their associated Backend. 50 | type Registry interface { 51 | Backend 52 | 53 | // Register a backend under the given name. 54 | RegisterBackend(name string, backend Backend) 55 | // Create a topic and register it to the Registry. 56 | Create(backendName, topicName string) error 57 | } 58 | -------------------------------------------------------------------------------- /validation/errors.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | type validationError map[string][]error 10 | 11 | func (e validationError) Error() string { 12 | var buf bytes.Buffer 13 | 14 | i := 0 15 | for k, errs := range e { 16 | if i > 0 { 17 | fmt.Fprintf(&buf, "; ") 18 | } 19 | 20 | for j := range errs { 21 | if j == 0 { 22 | fmt.Fprintf(&buf, "%s: ", k) 23 | } else if j > 0 { 24 | fmt.Fprintf(&buf, ",") 25 | } 26 | 27 | fmt.Fprintf(&buf, "%s", errs[j]) 28 | } 29 | 30 | i++ 31 | } 32 | 33 | return buf.String() 34 | } 35 | 36 | // MarshalJSON creates a JSON representation of the validation error. 37 | func (e validationError) MarshalJSON() ([]byte, error) { 38 | s := make(map[string][]string) 39 | 40 | for k, errs := range e { 41 | s[k] = make([]string, len(errs)) 42 | for i := range errs { 43 | s[k][i] = errs[i].Error() 44 | } 45 | } 46 | 47 | return json.Marshal(s) 48 | } 49 | 50 | // AddError adds an error under the given name. 51 | func AddError(verr error, name string, err error) error { 52 | var e validationError 53 | var ok bool 54 | 55 | if verr != nil { 56 | e, ok = verr.(validationError) 57 | if !ok { 58 | panic("incompatible error") 59 | } 60 | } 61 | 62 | if e == nil { 63 | e = make(validationError) 64 | } 65 | 66 | e[name] = append(e[name], err) 67 | return e 68 | } 69 | 70 | // LastError returns the last error for the specified field. 71 | func LastError(err error, name string) error { 72 | e, ok := err.(validationError) 73 | if !ok { 74 | return nil 75 | } 76 | 77 | errs, ok := e[name] 78 | if !ok || len(errs) == 0 { 79 | return nil 80 | } 81 | return errs[len(errs)-1] 82 | } 83 | 84 | // IsError tells if the given error is a validation error. 85 | func IsError(err error) bool { 86 | _, ok := err.(validationError) 87 | return ok 88 | } 89 | -------------------------------------------------------------------------------- /validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "github.com/asaskevich/govalidator" 5 | ) 6 | 7 | // Validate validates and saves all the govalidator errors in a ValidatorError. 8 | func Validate(s interface{}) error { 9 | ok, err := govalidator.ValidateStruct(s) 10 | if ok { 11 | return nil 12 | } 13 | 14 | errs, ok := err.(govalidator.Errors) 15 | if !ok || errs == nil { 16 | return nil 17 | } 18 | 19 | var verr validationError 20 | 21 | for i := range errs { 22 | switch t := errs[i].(type) { 23 | case govalidator.Errors: 24 | if len(t) == 0 { 25 | continue 26 | } 27 | e, ok := t[0].(govalidator.Error) 28 | if !ok { 29 | continue 30 | } 31 | 32 | verr = AddError(verr, e.Name, e.Err).(validationError) 33 | case govalidator.Error: 34 | verr = AddError(verr, t.Name, t.Err).(validationError) 35 | } 36 | } 37 | 38 | return verr 39 | } 40 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package lobby 2 | 3 | // Version represents the current build version. 4 | const Version = "v0.1.0-DEV" 5 | --------------------------------------------------------------------------------