├── .gitignore ├── auth ├── auth.go ├── service.go └── transport.go ├── session ├── sample_sessions.go ├── session.go └── service.go ├── user ├── sample_users.go ├── user_test.go ├── user.go ├── logging.go ├── instrumenting.go └── service.go ├── todo ├── sample_todos.go ├── todo_test.go ├── todo.go ├── service.go ├── logging.go └── instrumenting.go ├── granate.yaml ├── graphql ├── schema.graphql ├── instrumenting.go ├── service.go ├── logging.go └── transport.go ├── models ├── user.go ├── todo.go └── root.go ├── package.json ├── inmem ├── session.go ├── todo_test.go ├── user_test.go ├── todo.go └── user.go ├── redis └── session.go ├── README.md ├── gulpfile.js └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # The binary from go build 2 | go-kit-graphql-todo 3 | 4 | # Development dependencies 5 | node_modules 6 | 7 | # Files generated by granate (runs on `go generate`) 8 | schema 9 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | // Identifier Identifies the authenticated entity 4 | type Identifier string 5 | 6 | func (id Identifier) ToString() string { 7 | return string(id) 8 | } 9 | -------------------------------------------------------------------------------- /session/sample_sessions.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import "time" 4 | 5 | var ( 6 | Session1 = &Session{UID: "2C2E7C8D", 7 | Token: "a282e4ca-b74a-4f51-a27d-28bbf6287729", 8 | Expires: time.Now().Add(time.Minute * 60), 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /user/sample_users.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | var ( 4 | User1 = &User{ID: "2C2E7C8D", Name: "Jon", Email: "jon@jon.com", Password: "123"} 5 | User2 = &User{ID: "AC5CF9CF", Name: "Jim", Email: "jim@jim.com", Password: "123"} 6 | User3 = &User{ID: "CCAE5161", Name: "Jane", Email: "jane@jane.com", Password: "123"} 7 | User4 = &User{ID: "6E9BC35C", Name: "Joane", Email: "joane@joane.com", Password: "123"} 8 | ) 9 | -------------------------------------------------------------------------------- /todo/sample_todos.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 4 | 5 | var ( 6 | Todo1 = &Todo{ID: "7A421DFE", Text: "Learn some GraphQL", Done: true, OwnerID: user.User1.ID} 7 | Todo2 = &Todo{ID: "3C2120A0", Text: "Build an app", Done: false, OwnerID: user.User2.ID} 8 | Todo3 = &Todo{ID: "AF1BD873", Text: "Finish that project", Done: true, OwnerID: user.User2.ID} 9 | Todo4 = &Todo{ID: "BAA13A54", Text: "Eat breakfast", Done: true, OwnerID: user.User3.ID} 10 | ) 11 | -------------------------------------------------------------------------------- /granate.yaml: -------------------------------------------------------------------------------- 1 | # Programming language to output 2 | language: go 3 | 4 | # Output locations (and some options) 5 | output: 6 | # Name of the package to generate 7 | package: github.com/nicolaiskogheim/go-kit-graphql-todo 8 | 9 | # Name of the package to generate for the schema 10 | # These files doesn't need to be touched 11 | schema: schema 12 | 13 | # Name of the package to generate for the models 14 | # These files needs to be filled in by us 15 | models: models 16 | 17 | # Paths to schemas to use for code generation 18 | # Granate doesn't support multiple schemas yet. 19 | schemas: 20 | - graphql/schema.graphql 21 | -------------------------------------------------------------------------------- /graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Todo { 2 | id: ID! 3 | text: String! 4 | done: Boolean! 5 | owner: User 6 | } 7 | 8 | type User { 9 | id: ID! 10 | name: String! 11 | email: String! 12 | } 13 | 14 | # Query todos and users 15 | type Query { 16 | todos: [Todo] 17 | todo(id: ID!): Todo 18 | users: [User] 19 | user(id: ID): User 20 | viewer: User 21 | } 22 | 23 | type Mutation { 24 | addTodo(text: String!, done: Boolean = false, owner: String!): Todo 25 | toggleTodo(id: ID!): Todo 26 | deleteTodo(id: ID!): Todo 27 | addUser(name: String!, email: String!, password: String!): User 28 | } 29 | -------------------------------------------------------------------------------- /user/user_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "testing" 4 | 5 | var ( 6 | id = UserID("user_id") 7 | name = UserName("jimmy") 8 | email = UserEmail("jimmy@jimmy.com") 9 | password = UserPassword("password") 10 | ) 11 | 12 | func TestUser(t *testing.T) { 13 | u := New(id, name, email, password) 14 | 15 | if want, have := id, u.ID; want != have { 16 | t.Fatalf("want %+v, have %+v", want, have) 17 | } 18 | 19 | if want, have := name, u.Name; want != have { 20 | t.Fatalf("want %+v, have %+v", want, have) 21 | } 22 | 23 | if want, have := email, u.Email; want != have { 24 | t.Fatalf("want %+v, have %+v", want, have) 25 | } 26 | 27 | if want, have := password, u.Password; want != have { 28 | t.Fatalf("want %+v, have %+v", want, have) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/pborman/uuid" 8 | ) 9 | 10 | type SessionUID string 11 | 12 | func (uid SessionUID) ToString() string { 13 | return string(uid) 14 | } 15 | 16 | type SessionToken string 17 | 18 | func (token SessionToken) ToString() string { 19 | return string(token) 20 | } 21 | 22 | type Session struct { 23 | UID SessionUID 24 | Token SessionToken 25 | Expires time.Time 26 | } 27 | 28 | func New(uid SessionUID, expires time.Time) *Session { 29 | return &Session{ 30 | UID: uid, 31 | Token: newToken(), 32 | Expires: expires, 33 | } 34 | } 35 | 36 | func newToken() SessionToken { 37 | return SessionToken(uuid.New()) 38 | } 39 | 40 | // ErrUnknown is used when a session could not be found. 41 | var ErrUnknown = errors.New("unknown session") 42 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/nicolaiskogheim/go-kit-graphql-todo/schema" 5 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 6 | ) 7 | 8 | var _ schema.UserInterface = (*User)(nil) 9 | 10 | // User wraps user.User and stuff needed to resolve it 11 | type User struct { 12 | source user.User 13 | } 14 | 15 | // IdField resolves the ID field on user.User 16 | func (user User) IdField() (*string, error) { 17 | id := string(user.source.ID) 18 | return &id, nil 19 | } 20 | 21 | // NameField resolves the Name field on user.User 22 | func (user User) NameField() (*string, error) { 23 | name := string(user.source.Name) 24 | return &name, nil 25 | } 26 | 27 | // NameField resolves the Name field on user.User 28 | func (user User) EmailField() (*string, error) { 29 | email := string(user.source.Email) 30 | return &email, nil 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-utils", 3 | "version": "0.1.0", 4 | "description": "A test of graphql on a microservice architecture", 5 | "main": "main.go", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/nicolaiskogheim/go-kit-graphql-todo.git" 12 | }, 13 | "author": "Nicolai Skogheim (http://github.com/nicolaiskogheim)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/nicolaiskogheim/go-kit-graphql-todo/issues" 17 | }, 18 | "homepage": "https://github.com/nicolaiskogheim/go-kit-graphql-todo#readme", 19 | "devDependencies": { 20 | "child_process": "^1.0.2", 21 | "clear": "0.0.1", 22 | "gulp": "^3.9.1", 23 | "gulp-util": "^3.0.8", 24 | "run-sequence": "^2.1.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /graphql/instrumenting.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-kit/kit/metrics" 7 | "github.com/graphql-go/graphql" 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | type instrumentingService struct { 12 | requestCount metrics.Counter 13 | requestLatency metrics.Histogram 14 | Service 15 | } 16 | 17 | func NewInstrumentingService(counter metrics.Counter, latency metrics.Histogram, s Service) Service { 18 | return &instrumentingService{ 19 | requestCount: counter, 20 | requestLatency: latency, 21 | Service: s, 22 | } 23 | } 24 | 25 | func (s *instrumentingService) Do(ctx context.Context, request interface{}) *graphql.Result { 26 | defer func(begin time.Time) { 27 | s.requestCount.With("method", "do").Add(1) 28 | s.requestLatency.With("method", "do").Observe(time.Since(begin).Seconds()) 29 | }(time.Now()) 30 | 31 | return s.Service.Do(ctx, request) 32 | } 33 | -------------------------------------------------------------------------------- /graphql/service.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "github.com/graphql-go/graphql" 5 | "github.com/graphql-go/handler" 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | type Service interface { 10 | Do(ctx context.Context, request interface{}) *graphql.Result 11 | } 12 | 13 | type service struct { 14 | schema graphql.Schema 15 | } 16 | 17 | func NewService(schema *graphql.Schema) Service { 18 | return &service{ 19 | schema: *schema, 20 | } 21 | } 22 | 23 | func (s *service) Do(ctx context.Context, request interface{}) *graphql.Result { 24 | // TODO(nicolai): type alias RequestOptions to GraphqlRequest? 25 | options := request.(*handler.RequestOptions) 26 | params := graphql.Params{ 27 | Context: ctx, 28 | OperationName: options.OperationName, 29 | RequestString: options.Query, 30 | Schema: s.schema, 31 | VariableValues: options.Variables, 32 | } 33 | return graphql.Do(params) 34 | } 35 | -------------------------------------------------------------------------------- /session/service.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Service interface { 8 | Make(uid SessionUID, expires time.Time) (*SessionToken, error) 9 | Get(token SessionToken) (*Session, error) 10 | } 11 | 12 | type service struct { 13 | repository SessionRepository 14 | } 15 | 16 | func NewService(repository SessionRepository) Service { 17 | return &service{ 18 | repository: repository, 19 | } 20 | } 21 | 22 | func (s *service) Make(uid SessionUID, expires time.Time) (*SessionToken, error) { 23 | session := New(uid, expires) 24 | err := s.repository.Store(session) 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &session.Token, nil 31 | } 32 | 33 | func (s *service) Get(token SessionToken) (*Session, error) { 34 | session, err := s.repository.Find(SessionToken(token)) 35 | 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return session, nil 41 | } 42 | 43 | type SessionRepository interface { 44 | Find(token SessionToken) (*Session, error) 45 | Store(s *Session) error 46 | } 47 | -------------------------------------------------------------------------------- /todo/todo_test.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 7 | ) 8 | 9 | var ( 10 | id = TodoID("todo_id") 11 | text1 = TodoText("test todo") 12 | text2 = TodoText("updated text") 13 | notDone = TodoDone(false) 14 | done = TodoDone(true) 15 | owner = user.UserID(1) 16 | ) 17 | 18 | func TestTodo(t *testing.T) { 19 | t1 := New(id, text1, notDone, owner) 20 | 21 | if want, have := id, t1.ID; want != have { 22 | t.Fatalf("want %+v, have %+v", want, have) 23 | } 24 | 25 | if want, have := text1, t1.Text; want != have { 26 | t.Fatalf("want %+v, have %+v", want, have) 27 | } 28 | 29 | if want, have := notDone, t1.Done; want != have { 30 | t.Fatalf("want %+v, have %+v", want, have) 31 | } 32 | 33 | t1.UpdateText(text2) 34 | if want, have := text2, t1.Text; want != have { 35 | t.Fatalf("want %+v, have %+v", want, have) 36 | } 37 | 38 | t1.ToggleDone() 39 | if want, have := done, t1.Done; want != have { 40 | t.Fatalf("want %+v, have %+v", want, have) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /inmem/session.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/nicolaiskogheim/go-kit-graphql-todo/session" 8 | ) 9 | 10 | type sessionRepository struct { 11 | mtx sync.RWMutex 12 | sessions map[session.SessionToken]session.SessionUID 13 | } 14 | 15 | func (r *sessionRepository) Store(s *session.Session) error { 16 | r.mtx.Lock() 17 | defer r.mtx.Unlock() 18 | 19 | r.sessions[s.Token] = s.UID 20 | 21 | return nil 22 | } 23 | 24 | func (r *sessionRepository) Find(token session.SessionToken) (*session.Session, error) { 25 | r.mtx.RLock() 26 | defer r.mtx.RUnlock() 27 | 28 | if val, ok := r.sessions[token]; ok { 29 | s := &session.Session{ 30 | UID: val, 31 | Token: token, 32 | Expires: time.Now(), 33 | } 34 | 35 | return s, nil 36 | } 37 | 38 | return nil, session.ErrUnknown 39 | } 40 | 41 | func NewSessionRepository() session.SessionRepository { 42 | r := &sessionRepository{ 43 | sessions: make(map[session.SessionToken]session.SessionUID), 44 | } 45 | 46 | r.sessions[session.Session1.Token] = session.Session1.UID 47 | 48 | return r 49 | } 50 | -------------------------------------------------------------------------------- /graphql/logging.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-kit/kit/log" 9 | "github.com/graphql-go/graphql" 10 | "github.com/graphql-go/handler" 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | type loggingService struct { 15 | logger log.Logger 16 | Service 17 | } 18 | 19 | func NewLoggingService(logger log.Logger, s Service) Service { 20 | return &loggingService{logger, s} 21 | } 22 | 23 | func (s *loggingService) Do(ctx context.Context, request interface{}) (res *graphql.Result) { 24 | req := request.(*handler.RequestOptions) 25 | defer func(begin time.Time) { 26 | var err error 27 | if len(res.Errors) > 0 { 28 | err = fmt.Errorf("request error: %v", res.Errors) 29 | } 30 | 31 | // TODO(nicolai): Can/should we do anything with errors? 32 | variables, _ := json.Marshal(req.Variables) 33 | 34 | s.logger.Log( 35 | "method", "do", 36 | "took", time.Since(begin), 37 | "error", err, 38 | "operationName", req.OperationName, 39 | "variables", variables, 40 | "query", req.Query, 41 | ) 42 | }(time.Now()) 43 | res = s.Service.Do(ctx, request) 44 | return 45 | } 46 | -------------------------------------------------------------------------------- /todo/todo.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 8 | "github.com/pborman/uuid" 9 | ) 10 | 11 | // TodoID uniquely identifies a todo 12 | type TodoID string 13 | 14 | // Contents of a Todo 15 | type TodoText string 16 | 17 | // True for done, false otherwise 18 | type TodoDone bool 19 | 20 | type Todo struct { 21 | ID TodoID `json:"id"` 22 | Text TodoText `json:"text"` 23 | Done TodoDone `json:"done"` 24 | OwnerID user.UserID 25 | } 26 | 27 | func (t *Todo) UpdateText(text TodoText) { 28 | t.Text = text 29 | } 30 | 31 | func (t *Todo) ToggleDone() { 32 | t.Done = !t.Done 33 | } 34 | 35 | func New(id TodoID, text TodoText, done TodoDone, owner user.UserID) *Todo { 36 | return &Todo{ 37 | ID: id, 38 | Text: text, 39 | Done: done, 40 | OwnerID: owner, 41 | } 42 | } 43 | 44 | // ErrUnknown is used when a todo could not be found. 45 | var ErrUnknown = errors.New("unknown todo") 46 | 47 | // NextTodoID generates a new todo ID. 48 | // TODO: Move to infrastructure(?) 49 | func NextTodoID() TodoID { 50 | return TodoID(strings.Split(strings.ToUpper(uuid.New()), "-")[0]) 51 | } 52 | -------------------------------------------------------------------------------- /models/todo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/nicolaiskogheim/go-kit-graphql-todo/schema" 5 | "github.com/nicolaiskogheim/go-kit-graphql-todo/todo" 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 7 | ) 8 | 9 | var _ schema.TodoInterface = (*Todo)(nil) 10 | 11 | // Todo wraps todo.Todo and stuff needed to resolve it 12 | type Todo struct { 13 | source todo.Todo 14 | UserService user.Service 15 | } 16 | 17 | // IdField resolves the ID field on todo.Todo 18 | func (todo Todo) IdField() (*string, error) { 19 | id := string(todo.source.ID) 20 | return &id, nil 21 | } 22 | 23 | // TextField resolves the Text field on todo.Todo 24 | func (todo Todo) TextField() (*string, error) { 25 | text := string(todo.source.Text) 26 | return &text, nil 27 | } 28 | 29 | // DoneField resolves the Done field on todo.Todo 30 | func (todo Todo) DoneField() (*bool, error) { 31 | done := bool(todo.source.Done) 32 | return &done, nil 33 | } 34 | 35 | // OwnerField resolves the Owner field on todo.Todo 36 | func (todo Todo) OwnerField() (schema.UserInterface, error) { 37 | user, err := todo.UserService.Find(todo.source.OwnerID) 38 | 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return User{source: *user}, nil 44 | } 45 | -------------------------------------------------------------------------------- /user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/pborman/uuid" 8 | ) 9 | 10 | type UserID string 11 | 12 | func (id UserID) ToString() string { 13 | return string(id) 14 | } 15 | 16 | type UserName string 17 | 18 | func (name UserName) ToString() string { 19 | return string(name) 20 | } 21 | 22 | type UserEmail string 23 | 24 | func (email UserEmail) ToString() string { 25 | return string(email) 26 | } 27 | 28 | type UserPassword string 29 | 30 | func (password UserPassword) ToString() string { 31 | return string(password) 32 | } 33 | 34 | type User struct { 35 | ID UserID `json:"id"` 36 | Name UserName `json:"name"` 37 | Email UserEmail `json:"email"` 38 | Password UserPassword `json:"password"` 39 | } 40 | 41 | func New(id UserID, name UserName, email UserEmail, password UserPassword) *User { 42 | return &User{ 43 | ID: id, 44 | Name: name, 45 | Email: email, 46 | Password: password, 47 | } 48 | } 49 | 50 | // ErrUnknown is used when a user could not be found. 51 | var ErrUnknown = errors.New("unknown user") 52 | 53 | // NextUserID generates a new UserID. 54 | func NextUserID() UserID { 55 | return UserID(strings.Split(strings.ToUpper(uuid.New()), "-")[0]) 56 | } 57 | -------------------------------------------------------------------------------- /inmem/todo_test.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/todo" 7 | ) 8 | 9 | func TestInmem(t *testing.T) { 10 | 11 | tr := &todoRepository{ 12 | todos: make(map[todo.TodoID]*todo.Todo), 13 | } 14 | 15 | todo1 := &todo.Todo{ID: "123", Text: "Test Inmem", Done: false} 16 | 17 | // Repo should not contain anything 18 | if want, have := 0, len(tr.FindAll()); want != have { 19 | t.Fatalf("want %+v, have %+v", want, have) 20 | } 21 | 22 | tr.Store(todo1) 23 | 24 | t1, err := tr.Find(todo1.ID) 25 | if err != nil { 26 | t.Fatalf("should have found todo, got %s", err.Error()) 27 | } 28 | 29 | if want, have := todo1.ID, t1.ID; want != have { 30 | t.Fatalf("want %+v, have %+v", want, have) 31 | } 32 | 33 | if want, have := todo1.Text, t1.Text; want != have { 34 | t.Fatalf("want %+v, have %+v", want, have) 35 | } 36 | 37 | if want, have := todo1.Done, t1.Done; want != have { 38 | t.Fatalf("want %+v, have %+v", want, have) 39 | } 40 | 41 | ts := tr.FindAll() 42 | if want, have := 1, len(ts); want != have { 43 | t.Fatalf("want %d, have %d", want, have) 44 | } 45 | 46 | t1, err = tr.Delete(todo1.ID) 47 | if err != nil { 48 | t.Fatalf("should delete todo, got %s", err.Error()) 49 | } 50 | 51 | if want, have := 0, len(tr.FindAll()); want != have { 52 | t.Fatalf("want %d, have %d", want, have) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /redis/session.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/go-redis/redis" 8 | "github.com/nicolaiskogheim/go-kit-graphql-todo/session" 9 | ) 10 | 11 | type sessionRepository struct { 12 | client redis.Client 13 | } 14 | 15 | func (r *sessionRepository) Find(token session.SessionToken) (*session.Session, error) { 16 | val, err := r.client.Get(token.ToString()).Result() 17 | 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | s := session.Session{UID: session.SessionUID(val), 23 | Token: token, 24 | Expires: time.Now(), 25 | } 26 | 27 | return &s, nil 28 | } 29 | 30 | func (r *sessionRepository) Store(s *session.Session) error { 31 | expires := s.Expires.Sub(time.Now()) 32 | err := r.client.Set(s.Token.ToString(), s.UID.ToString(), expires).Err() 33 | 34 | return err 35 | } 36 | 37 | func NewSessionRepository(client redis.Client) session.SessionRepository { 38 | 39 | // XXX(nicolai): This is how we check that the client is alive 40 | // We may want to do this in regular intervals and alert something 41 | // when/if the client dies. Or maybe there are recovery strategies 42 | // for this. If this app is orchestrated by Kubernetes, then 43 | // Kubernetes could be responsible for keeping redis alive. 44 | pong, err := client.Ping().Result() 45 | fmt.Println(pong, err) 46 | // Output: PONG 47 | 48 | r := &sessionRepository{ 49 | client: client, 50 | } 51 | 52 | return r 53 | } 54 | -------------------------------------------------------------------------------- /user/logging.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-kit/kit/log" 7 | ) 8 | 9 | type loggingService struct { 10 | logger log.Logger 11 | Service 12 | } 13 | 14 | func NewLoggingService(logger log.Logger, s Service) Service { 15 | return &loggingService{ 16 | logger: logger, 17 | Service: s, 18 | } 19 | } 20 | 21 | func (s *loggingService) Add(u *User) (err error) { 22 | defer func(begin time.Time) { 23 | s.logger.Log( 24 | "method", "add", 25 | "id", u.ID, 26 | "name", u.Name, 27 | "took", time.Since(begin), 28 | "error", err, 29 | ) 30 | }(time.Now()) 31 | 32 | return s.Service.Add(u) 33 | } 34 | 35 | func (s *loggingService) Remove(id UserID) (err error) { 36 | defer func(begin time.Time) { 37 | s.logger.Log( 38 | "method", "remove", 39 | "id", id, 40 | "took", time.Since(begin), 41 | "error", err, 42 | ) 43 | }(time.Now()) 44 | 45 | return s.Service.Remove(id) 46 | } 47 | 48 | func (s *loggingService) Find(id UserID) (u *User, err error) { 49 | defer func(begin time.Time) { 50 | s.logger.Log( 51 | "method", "find", 52 | "id", id, 53 | "took", time.Since(begin), 54 | "error", err, 55 | ) 56 | }(time.Now()) 57 | 58 | return s.Service.Find(id) 59 | } 60 | 61 | func (s *loggingService) FindAll() []*User { 62 | defer func(begin time.Time) { 63 | s.logger.Log( 64 | "method", "find_all", 65 | "took", time.Since(begin), 66 | ) 67 | }(time.Now()) 68 | return s.Service.FindAll() 69 | } 70 | -------------------------------------------------------------------------------- /todo/service.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 7 | ) 8 | 9 | type Service interface { 10 | Add(t *Todo) error 11 | Toggle(user user.User, id TodoID) (*Todo, error) 12 | Remove(id TodoID) (*Todo, error) 13 | FindAll() []*Todo 14 | Find(id TodoID) (*Todo, error) 15 | } 16 | 17 | type service struct { 18 | repository TodoRepository 19 | } 20 | 21 | func NewService(repository TodoRepository) Service { 22 | return &service{ 23 | repository: repository, 24 | } 25 | } 26 | 27 | func (s *service) Add(t *Todo) error { 28 | return s.repository.Store(t) 29 | } 30 | 31 | func (s *service) Toggle(user user.User, id TodoID) (*Todo, error) { 32 | 33 | t, err := s.repository.Find(id) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if user.ID == t.OwnerID { 39 | t.ToggleDone() 40 | s.repository.Update(t) 41 | } else { 42 | return nil, errors.New("user is not owner of todo") 43 | } 44 | 45 | return t, nil 46 | } 47 | 48 | func (s *service) Remove(id TodoID) (*Todo, error) { 49 | return s.repository.Delete(id) 50 | } 51 | 52 | func (s *service) Find(id TodoID) (*Todo, error) { 53 | return s.repository.Find(id) 54 | } 55 | 56 | func (s *service) FindAll() []*Todo { 57 | return s.repository.FindAll() 58 | } 59 | 60 | type TodoRepository interface { 61 | Store(t *Todo) error 62 | Update(t *Todo) error 63 | Delete(id TodoID) (*Todo, error) 64 | Find(id TodoID) (*Todo, error) 65 | FindAll() []*Todo 66 | } 67 | -------------------------------------------------------------------------------- /inmem/user_test.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 7 | ) 8 | 9 | func TestInmem(t *testing.T) { 10 | r := &userRepository{ 11 | users: make(map[user.UserID]*user.User), 12 | } 13 | 14 | user := &users.User{ID: "123", Name: "jon", Email: "jon@jon.com", Password: "pass"} 15 | 16 | // Repo should not contain anything 17 | if want, have := 0, len(r.FindAll()); want != have { 18 | t.Fatalf("want %+v, have %+v", want, have) 19 | } 20 | 21 | r.Store(user) 22 | 23 | u, err := r.Find(user.ID) 24 | if err != nil { 25 | t.Fatalf("should have found user, got %s", err) 26 | } 27 | 28 | if want, have := u.ID, user.ID; want != have { 29 | t.Fatalf("want %+v, have %+v", want, have) 30 | } 31 | 32 | if want, have := u.Name, user.Name; want != have { 33 | t.Fatalf("want %+v, have %+v", want, have) 34 | } 35 | 36 | if want, have := u.Email, user.Email; want != have { 37 | t.Fatalf("want %+v, have %+v", want, have) 38 | } 39 | 40 | if want, have := u.Password, user.Password; want != have { 41 | t.Fatalf("want %+v, have %+v", want, have) 42 | } 43 | 44 | us := r.FindAll() 45 | if want, have := 1, len(r); want != have { 46 | t.Fatalf("repo should contain %d, but contains %d users", want, have) 47 | } 48 | 49 | err = tr.Delete(todo1.ID) 50 | if err != nil { 51 | t.Fatalf("should delete todo, got %s", err.Error()) 52 | } 53 | 54 | if want, have := 0, len(tr.FindAll()); want != have { 55 | t.Fatalf("repo should contain %d, but contains %d users", want, have) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /user/instrumenting.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-kit/kit/metrics" 7 | ) 8 | 9 | type instrumentingService struct { 10 | requestCount metrics.Counter 11 | requestLatency metrics.Histogram 12 | Service 13 | } 14 | 15 | func NewInstrumentingService(counter metrics.Counter, latency metrics.Histogram, s Service) Service { 16 | return &instrumentingService{ 17 | requestCount: counter, 18 | requestLatency: latency, 19 | Service: s, 20 | } 21 | } 22 | 23 | func (s *instrumentingService) Add(u *User) error { 24 | defer func(begin time.Time) { 25 | s.requestCount.With("method", "add").Add(1) 26 | s.requestLatency.With("method", "add").Observe(time.Since(begin).Seconds()) 27 | }(time.Now()) 28 | 29 | return s.Service.Add(u) 30 | } 31 | 32 | func (s *instrumentingService) Remove(id UserID) error { 33 | defer func(begin time.Time) { 34 | s.requestCount.With("method", "remove").Add(1) 35 | s.requestLatency.With("method", "remove").Observe(time.Since(begin).Seconds()) 36 | }(time.Now()) 37 | 38 | return s.Service.Remove(id) 39 | } 40 | 41 | func (s *instrumentingService) Find(id UserID) (*User, error) { 42 | defer func(begin time.Time) { 43 | s.requestCount.With("method", "find").Add(1) 44 | s.requestLatency.With("method", "find").Observe(time.Since(begin).Seconds()) 45 | }(time.Now()) 46 | 47 | return s.Service.Find(id) 48 | } 49 | 50 | func (s *instrumentingService) FindAll() []*User { 51 | defer func(begin time.Time) { 52 | s.requestCount.With("method", "find_all").Add(1) 53 | s.requestLatency.With("method", "find_all").Observe(time.Since(begin).Seconds()) 54 | }(time.Now()) 55 | 56 | return s.Service.FindAll() 57 | } 58 | -------------------------------------------------------------------------------- /user/service.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/nicolaiskogheim/go-kit-graphql-todo/auth" 8 | ) 9 | 10 | type Service interface { 11 | Add(u *User) error 12 | Remove(id UserID) error 13 | Find(id UserID) (*User, error) 14 | FindAll() []*User 15 | Authenticate(req http.Request) (*auth.Identifier, error) 16 | } 17 | 18 | type service struct { 19 | repository UserRepository 20 | } 21 | 22 | func NewService(repository UserRepository) Service { 23 | return &service{ 24 | repository: repository, 25 | } 26 | } 27 | 28 | func (s *service) Add(u *User) error { 29 | return s.repository.Store(u) 30 | } 31 | 32 | func (s *service) Remove(id UserID) error { 33 | return s.repository.Delete(id) 34 | } 35 | 36 | func (s *service) Find(id UserID) (*User, error) { 37 | return s.repository.Find(id) 38 | } 39 | 40 | func (s *service) FindAll() []*User { 41 | return s.repository.FindAll() 42 | } 43 | 44 | func (s *service) Authenticate(req http.Request) (*auth.Identifier, error) { 45 | 46 | req.ParseForm() 47 | password := req.Form.Get("password") 48 | email := req.Form.Get("email") 49 | 50 | user, err := s.repository.FindByCredentials( 51 | UserEmail(email), 52 | UserPassword(password), 53 | ) 54 | 55 | if err == ErrUnknown { 56 | return nil, errors.New("Wrong email or password") 57 | } 58 | 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | identifier := auth.Identifier(user.ID.ToString()) 64 | return &identifier, nil 65 | } 66 | 67 | type UserRepository interface { 68 | Store(u *User) error 69 | Update(t *User) error 70 | Delete(id UserID) error 71 | Find(id UserID) (*User, error) 72 | FindAll() []*User 73 | FindByCredentials(email UserEmail, password UserPassword) (*User, error) 74 | } 75 | -------------------------------------------------------------------------------- /inmem/todo.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/todo" 7 | ) 8 | 9 | type todoRepository struct { 10 | mtx sync.RWMutex 11 | todos map[todo.TodoID]*todo.Todo 12 | } 13 | 14 | func (r *todoRepository) Store(t *todo.Todo) error { 15 | r.mtx.Lock() 16 | defer r.mtx.Unlock() 17 | 18 | r.todos[t.ID] = t 19 | 20 | return nil 21 | } 22 | 23 | func (r *todoRepository) Update(t *todo.Todo) error { 24 | r.mtx.Lock() 25 | defer r.mtx.Unlock() 26 | 27 | // TODO(nicolai): what the fuck 28 | if _, ok := r.todos[t.ID]; ok { 29 | return todo.ErrUnknown 30 | } 31 | 32 | r.todos[t.ID] = t 33 | return nil 34 | } 35 | 36 | func (r *todoRepository) Delete(id todo.TodoID) (*todo.Todo, error) { 37 | r.mtx.Lock() 38 | defer r.mtx.Unlock() 39 | 40 | if val, ok := r.todos[id]; ok { 41 | delete(r.todos, id) 42 | return val, nil 43 | } 44 | 45 | return nil, todo.ErrUnknown 46 | } 47 | 48 | func (r *todoRepository) Find(id todo.TodoID) (*todo.Todo, error) { 49 | r.mtx.RLock() 50 | defer r.mtx.RUnlock() 51 | 52 | if val, ok := r.todos[id]; ok { 53 | return val, nil 54 | } 55 | 56 | return nil, todo.ErrUnknown 57 | } 58 | 59 | func (r *todoRepository) FindAll() []*todo.Todo { 60 | r.mtx.RLock() 61 | defer r.mtx.RUnlock() 62 | 63 | todos := make([]*todo.Todo, 0, len(r.todos)) 64 | for _, val := range r.todos { 65 | todos = append(todos, val) 66 | } 67 | 68 | return todos 69 | } 70 | 71 | func NewTodoRepository() todo.TodoRepository { 72 | r := &todoRepository{ 73 | todos: make(map[todo.TodoID]*todo.Todo), 74 | } 75 | 76 | r.todos[todo.Todo1.ID] = todo.Todo1 77 | r.todos[todo.Todo2.ID] = todo.Todo2 78 | r.todos[todo.Todo3.ID] = todo.Todo3 79 | r.todos[todo.Todo4.ID] = todo.Todo4 80 | 81 | return r 82 | } 83 | -------------------------------------------------------------------------------- /todo/logging.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-kit/kit/log" 7 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 8 | ) 9 | 10 | type loggingService struct { 11 | logger log.Logger 12 | Service 13 | } 14 | 15 | func NewLoggingService(logger log.Logger, s Service) Service { 16 | return &loggingService{ 17 | logger: logger, 18 | Service: s, 19 | } 20 | } 21 | 22 | func (s *loggingService) Add(t *Todo) (err error) { 23 | defer func(begin time.Time) { 24 | s.logger.Log( 25 | "method", "add", 26 | "id", t.ID, 27 | "text", t.Text, 28 | "done", t.Done, 29 | "took", time.Since(begin), 30 | "error", err, 31 | ) 32 | }(time.Now()) 33 | 34 | return s.Service.Add(t) 35 | } 36 | 37 | func (s *loggingService) Toggle(user user.User, id TodoID) (t *Todo, err error) { 38 | defer func(begin time.Time) { 39 | s.logger.Log( 40 | "method", "toggle", 41 | "id", id, 42 | "took", time.Since(begin), 43 | "error", err, 44 | ) 45 | }(time.Now()) 46 | 47 | return s.Service.Toggle(user, id) 48 | } 49 | 50 | func (s *loggingService) Remove(id TodoID) (t *Todo, err error) { 51 | defer func(begin time.Time) { 52 | s.logger.Log( 53 | "method", "remove", 54 | "id", id, 55 | "took", time.Since(begin), 56 | "error", err, 57 | ) 58 | }(time.Now()) 59 | 60 | return s.Service.Remove(id) 61 | } 62 | 63 | func (s *loggingService) Find(id TodoID) (t *Todo, err error) { 64 | defer func(begin time.Time) { 65 | s.logger.Log( 66 | "method", "find", 67 | "id", id, 68 | "took", time.Since(begin), 69 | "error", err, 70 | ) 71 | }(time.Now()) 72 | 73 | return s.Service.Find(id) 74 | } 75 | 76 | func (s *loggingService) FindAll() []*Todo { 77 | defer func(begin time.Time) { 78 | s.logger.Log( 79 | "method", "find_all", 80 | "took", time.Since(begin), 81 | ) 82 | }(time.Now()) 83 | 84 | return s.Service.FindAll() 85 | } 86 | -------------------------------------------------------------------------------- /todo/instrumenting.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-kit/kit/metrics" 7 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 8 | ) 9 | 10 | type instrumentingService struct { 11 | requestCount metrics.Counter 12 | requestLatency metrics.Histogram 13 | Service 14 | } 15 | 16 | func NewInstrumentingService(counter metrics.Counter, latency metrics.Histogram, s Service) Service { 17 | return &instrumentingService{ 18 | requestCount: counter, 19 | requestLatency: latency, 20 | Service: s, 21 | } 22 | } 23 | 24 | func (s *instrumentingService) Add(t *Todo) error { 25 | defer func(begin time.Time) { 26 | s.requestCount.With("method", "add").Add(1) 27 | s.requestLatency.With("method", "add").Observe(time.Since(begin).Seconds()) 28 | }(time.Now()) 29 | 30 | return s.Service.Add(t) 31 | } 32 | 33 | func (s *instrumentingService) Toggle(user user.User, id TodoID) (*Todo, error) { 34 | defer func(begin time.Time) { 35 | s.requestCount.With("method", "toggle").Add(1) 36 | s.requestLatency.With("method", "toggle").Observe(time.Since(begin).Seconds()) 37 | }(time.Now()) 38 | 39 | return s.Service.Toggle(user, id) 40 | } 41 | 42 | func (s *instrumentingService) Remove(id TodoID) (*Todo, error) { 43 | defer func(begin time.Time) { 44 | s.requestCount.With("method", "remove").Add(1) 45 | s.requestLatency.With("method", "remove").Observe(time.Since(begin).Seconds()) 46 | }(time.Now()) 47 | 48 | return s.Service.Remove(id) 49 | } 50 | 51 | func (s *instrumentingService) FindAll() []*Todo { 52 | defer func(begin time.Time) { 53 | s.requestCount.With("method", "find_all").Add(1) 54 | s.requestLatency.With("method", "find_all").Observe(time.Since(begin).Seconds()) 55 | }(time.Now()) 56 | 57 | return s.Service.FindAll() 58 | } 59 | 60 | func (s *instrumentingService) Find(id TodoID) (*Todo, error) { 61 | defer func(begin time.Time) { 62 | s.requestCount.With("method", "find").Add(1) 63 | s.requestLatency.With("method", "find").Observe(time.Since(begin).Seconds()) 64 | }(time.Now()) 65 | 66 | return s.Service.Find(id) 67 | } 68 | -------------------------------------------------------------------------------- /inmem/user.go: -------------------------------------------------------------------------------- 1 | package inmem 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 7 | ) 8 | 9 | type userRepository struct { 10 | mtx sync.RWMutex 11 | users map[user.UserID]*user.User 12 | } 13 | 14 | func (r *userRepository) Store(u *user.User) error { 15 | r.mtx.Lock() 16 | defer r.mtx.Unlock() 17 | 18 | r.users[u.ID] = u 19 | 20 | return nil 21 | } 22 | 23 | func (r *userRepository) Update(u *user.User) error { 24 | r.mtx.Lock() 25 | defer r.mtx.Unlock() 26 | 27 | if _, ok := r.users[u.ID]; ok { 28 | r.users[u.ID] = u 29 | return nil 30 | } 31 | 32 | return user.ErrUnknown 33 | } 34 | 35 | func (r *userRepository) Delete(id user.UserID) error { 36 | r.mtx.Lock() 37 | defer r.mtx.Unlock() 38 | 39 | if _, ok := r.users[id]; ok { 40 | delete(r.users, id) 41 | return nil 42 | } 43 | 44 | return user.ErrUnknown 45 | } 46 | 47 | func (r *userRepository) Find(id user.UserID) (*user.User, error) { 48 | r.mtx.RLock() 49 | defer r.mtx.RUnlock() 50 | 51 | if val, ok := r.users[id]; ok { 52 | return val, nil 53 | } 54 | 55 | return nil, user.ErrUnknown 56 | } 57 | 58 | func (r *userRepository) FindAll() []*user.User { 59 | r.mtx.RLock() 60 | defer r.mtx.RUnlock() 61 | 62 | users := make([]*user.User, 0, len(r.users)) 63 | for _, val := range r.users { 64 | users = append(users, val) 65 | } 66 | 67 | return users 68 | } 69 | 70 | func (r *userRepository) FindByCredentials(email user.UserEmail, password user.UserPassword) (*user.User, error) { 71 | r.mtx.RLock() 72 | defer r.mtx.RUnlock() 73 | 74 | for _, user := range r.users { 75 | if user.Email == email { 76 | if user.Password == password { 77 | return user, nil 78 | } 79 | } 80 | } 81 | 82 | return nil, user.ErrUnknown 83 | } 84 | 85 | func NewUserRepository() user.UserRepository { 86 | r := &userRepository{ 87 | users: make(map[user.UserID]*user.User), 88 | } 89 | 90 | r.users[user.User1.ID] = user.User1 91 | r.users[user.User2.ID] = user.User2 92 | r.users[user.User3.ID] = user.User3 93 | r.users[user.User4.ID] = user.User4 94 | 95 | return r 96 | } 97 | -------------------------------------------------------------------------------- /auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/nicolaiskogheim/go-kit-graphql-todo/session" 9 | ) 10 | 11 | type ContextKey int 12 | 13 | const ( 14 | AuthContextID ContextKey = iota 15 | ) 16 | 17 | func (id ContextKey) ToInt() int { 18 | return int(id) 19 | } 20 | 21 | type Service interface { 22 | Authenticate(ctx context.Context, request *http.Request) context.Context 23 | Login(req *http.Request) (*session.SessionToken, error) 24 | } 25 | 26 | type Authenticatable interface { 27 | Authenticate(req http.Request) (*Identifier, error) 28 | } 29 | 30 | type service struct { 31 | session session.Service 32 | authable Authenticatable 33 | cookieName string 34 | } 35 | 36 | func (s *service) Authenticate(ctx context.Context, request *http.Request) context.Context { 37 | cookie, err := request.Cookie("session") 38 | 39 | if err != nil { 40 | return ctx 41 | } 42 | 43 | sess, err := s.session.Get(session.SessionToken(cookie.Value)) 44 | 45 | if err != nil { 46 | return ctx 47 | } 48 | 49 | return context.WithValue(ctx, AuthContextID, sess.UID) 50 | } 51 | 52 | func (s *service) Login(req *http.Request) (*session.SessionToken, error) { 53 | id, err := s.authable.Authenticate(*req) 54 | 55 | if err != nil || id == nil { 56 | return nil, err 57 | } 58 | 59 | token, err := s.session.Make(session.SessionUID(id.ToString()), time.Date( 60 | time.Now().Year()+1, 61 | time.January, 62 | 0, 0, 0, 0, 0, 63 | time.FixedZone("Europe/Oslo", 0)), 64 | ) 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return token, nil 71 | } 72 | 73 | // Viewer returns the identifier of an authorized viewer 74 | // it returns nil if no viewer was found in the session 75 | func Viewer(ctx context.Context) *Identifier { 76 | val := ctx.Value(AuthContextID) 77 | if val == nil { 78 | return nil 79 | } 80 | 81 | uid, ok := val.(session.SessionUID) 82 | if ok == false { 83 | return nil 84 | } 85 | 86 | id := Identifier(uid) 87 | 88 | return &id 89 | } 90 | 91 | func NewService(session session.Service, authable Authenticatable) Service { 92 | return &service{ 93 | session: session, 94 | authable: authable, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /auth/transport.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "time" 9 | 10 | kithttp "github.com/go-kit/kit/transport/http" 11 | 12 | "github.com/go-kit/kit/endpoint" 13 | "github.com/go-kit/kit/log" 14 | ) 15 | 16 | func MakeHandler(s Service, logger log.Logger) http.Handler { 17 | opts := []kithttp.ServerOption{ 18 | kithttp.ServerErrorLogger(logger), 19 | kithttp.ServerErrorEncoder(encodeError), 20 | } 21 | 22 | var authEndpoint endpoint.Endpoint 23 | { 24 | authLogger := log.With(logger, "method", "auth") 25 | 26 | authEndpoint = makeAuthEndpoint(s) 27 | authEndpoint = makeLoggingAuthEndpoint(authLogger)(authEndpoint) 28 | } 29 | 30 | authHandler := kithttp.NewServer( 31 | authEndpoint, 32 | decodeAuthRequest, 33 | encodeAuthResponse, 34 | opts..., 35 | ) 36 | 37 | return authHandler 38 | } 39 | 40 | func makeAuthEndpoint(s Service) endpoint.Endpoint { 41 | return func(ctx context.Context, request interface{}) (interface{}, error) { 42 | req := request.(*http.Request) 43 | token, err := s.Login(req) 44 | 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | cookie := http.Cookie{ 50 | Name: "session", 51 | Value: token.ToString(), 52 | HttpOnly: true, 53 | } 54 | 55 | return cookie, nil 56 | } 57 | } 58 | 59 | func makeLoggingAuthEndpoint(logger log.Logger) endpoint.Middleware { 60 | return func(next endpoint.Endpoint) endpoint.Endpoint { 61 | return func(ctx context.Context, request interface{}) (response interface{}, err error) { 62 | defer func(begin time.Time) { 63 | logger.Log("error", err, "took", time.Since(begin)) 64 | }(time.Now()) 65 | return next(ctx, request) 66 | } 67 | } 68 | } 69 | 70 | func decodeAuthRequest(_ context.Context, req *http.Request) (interface{}, error) { 71 | return req, nil 72 | } 73 | 74 | func encodeAuthResponse(ctx context.Context, rw http.ResponseWriter, response interface{}) error { 75 | 76 | cookie, ok := response.(http.Cookie) 77 | if ok == false { 78 | return errors.New("Bad response") 79 | } 80 | 81 | http.SetCookie(rw, &cookie) 82 | rw.WriteHeader(http.StatusOK) 83 | 84 | return nil 85 | 86 | } 87 | 88 | func encodeError(_ context.Context, err error, rw http.ResponseWriter) { 89 | rw.WriteHeader(http.StatusUnauthorized) 90 | rw.Header().Set("Content-Type", "application/json; charset= utf-8") 91 | 92 | json.NewEncoder(rw).Encode(map[string]interface{}{ 93 | "error": err.Error(), 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todo Microservice 2 | 3 | There was a list of technologies I wanted to use for a project, and this todo 4 | app is a test to see how that would work. 5 | 6 | This is just the server, but you can interact with it with 7 | [GraphiQL](https://github.com/graphql/graphiql), which is also served on 8 | `localhost:8080/`. As I'm going to use Elm for the frontend in said project, 9 | there will soon(-ish) be a client available in another repo for speaking with 10 | this server. 11 | 12 | ### Contributing 13 | If you want to, in any way, contribute to this experiment, or if you have 14 | questions about anything, feel free to open issues or PR's. 15 | 16 | ### Technology/library roadmap 17 | - [x] go-kit 18 | - [x] graphql with [graphql-go](https://github.com/graphql-go/graphql) and [granate](https://github.com/granateio/granate) 19 | - [ ] paging of todos 20 | - [x] logging (go-kit) 21 | - [x] instrumenting (prometheus) 22 | - [ ] opentracing (with appdash) 23 | - [x] client authentication 24 | - [ ] authorisation *(in progress)* 25 | - [ ] Proxying? 26 | - [ ] Load balancing? 27 | - [ ] Circuit breaking? 28 | - [ ] Throtling? 29 | 30 | ### Roadmap otherwise 31 | - [x] Todos 32 | - [x] Users 33 | - [x] Login 34 | 35 | While I'm at it I want to try out a whole bunch of things. The `todo service` 36 | is exposed with graphql, but I want to expose it via a REST-ful api as well. 37 | This is both to see if the architecture would allow it without making 38 | compromises, and to test whether abstractions are done properly, or if logic 39 | leaks where it shouldn't. The REST stuff has low priority, though. 40 | 41 | ### Running 42 | ``` 43 | $ cd $PROJECT_ROOT 44 | $ go get . # Install dependencies 45 | $ npm install # or `yarn` 46 | $ gulp 47 | ``` 48 | 49 | Visit `localhost:8080/` to interact with the server through GraphiQL. 50 | You can visit `localhost:8080/metrics` to see data from 51 | instrumentation. 52 | Use `localhost:8080/graphql` if you need to speak directly with the GraphQL API. 53 | 54 | ### Inspiration 55 | - [go-kit/kit](github.com/go-kit/kit) (shipping examle in particular) 56 | - [narqo/test-graphql](https://github.com/narqo/test-graphql) 57 | 58 | ### Resources 59 | - [graphql.org](http://graphql.org/learn/) 60 | - [opentracing.io](http://opentracing.io/documentation/) 61 | - [graphql-go](https://github.com/graphql-go/graphql) 62 | - [granate](https://github.com/granateio/granate) 63 | - [Microservice pattern](http://microservices.io/patterns/microservices.html) 64 | - [Hexagonal architecture](http://alistair.cockburn.us/Hexagonal+architecture) 65 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | // jshint undef: true, unused: true, strict: true, node: true, laxcomma: true 2 | 'use strict'; 3 | 4 | var gulp = require('gulp') 5 | , clear = require('clear') 6 | , exec = require('child_process').exec 7 | , gutil = require('gulp-util') 8 | , runSequence = require('run-sequence') 9 | , spawn = require('child_process').spawn 10 | , counter = 0 11 | , node = null 12 | ; 13 | 14 | var build = 'go build'; 15 | var generate = 'go generate'; 16 | var run = './go-kit-graphql-todo'; 17 | 18 | // gulp.task('default', ['generate', 'build', 'run', 'watch']); 19 | gulp.task('default', function(cb) { 20 | runSequence('generate', 21 | 'run', 22 | 'watch', 23 | cb); 24 | }); 25 | 26 | gulp.task('watch', function() { 27 | gulp.watch('**/*.go', ['clear', 'run']) 28 | // We don't need to run 'build' after generate. 29 | // If any *.go files are added/updated then 'build' 30 | // will trigger anyway 31 | // XXX(nicolaiskogheim): I don't know if this behaviour creates 32 | // race conditions. 33 | gulp.watch('**/*.graphql', ['clear', 'generate']); 34 | }); 35 | 36 | gulp.task('clear', [], function (cb) { 37 | // The first 'generate' triggers 'build', but we don't want to clear the 38 | // screen. This is because granate touches files even if there's nothing 39 | // new. 40 | if (counter != 0) { 41 | clear(); 42 | } 43 | cb(); 44 | }); 45 | 46 | gulp.task('build', [], function (cb) { 47 | runCommand(build, cb); 48 | }); 49 | 50 | gulp.task('generate', [], function (cb) { 51 | runCommand(generate, cb); 52 | }); 53 | 54 | gulp.task('run', ['build'], function(cb) { 55 | gutil.log(gutil.colors.yellow('Run number ' + counter)); 56 | 57 | if (node) { 58 | node.kill(); 59 | // Delay this so we get 'Starting ...' after 'Server quit ...' 60 | setTimeout(function() { 61 | gutil.log(gutil.colors.cyan('Starting server.')); 62 | }, 5); 63 | } 64 | 65 | node = spawn(run, [], {stdio: 'inherit'}); 66 | node.on('exit', function (code, signal) { 67 | gutil.log(gutil.colors.cyan('Server quit. Exit code: ', code, '. Signal: ', signal)); 68 | }); 69 | 70 | counter++; 71 | cb(); 72 | }); 73 | 74 | process.on('exit', function() { 75 | if (node) node.kill(); 76 | }); 77 | 78 | function runCommand(cmd, cb) { 79 | exec(cmd, function (err, stdout, stderr) { 80 | if (err) { 81 | gutil.log(gutil.colors.red(cmd + ': '), gutil.colors.red(stderr)); 82 | gutil.log(gutil.colors.red('NOT RESTARTING APP')); 83 | } else { 84 | if (stdout) { 85 | gutil.log(gutil.colors.green(cmd + ': '), gutil.colors.green(stdout)); 86 | } 87 | gutil.log(gutil.colors.green('RESTARTING APP')); 88 | } 89 | cb(); 90 | }); 91 | } 92 | 93 | -------------------------------------------------------------------------------- /graphql/transport.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-kit/kit/endpoint" 10 | "github.com/go-kit/kit/log" 11 | kithttp "github.com/go-kit/kit/transport/http" 12 | "github.com/graphql-go/handler" 13 | // kittracing "github.com/go-kit/kit/tracing/opentracing" 14 | // "github.com/opentracing/opentracing-go" 15 | "context" 16 | ) 17 | 18 | func MakeHandler(gqs Service, logger log.Logger, serveropts ...kithttp.ServerOption) http.Handler { 19 | opts := []kithttp.ServerOption{ 20 | kithttp.ServerErrorLogger(logger), 21 | kithttp.ServerErrorEncoder(encodeError), 22 | } 23 | 24 | opts = append(opts, serveropts...) 25 | 26 | var graphqlEndpoint endpoint.Endpoint 27 | { 28 | graphqlLogger := log.With(logger, "method", "graphql") 29 | 30 | graphqlEndpoint = makeGraphqlEndpoint(gqs) 31 | // graphqlEndpoint = kittracing.TracerServer(tracer, "graphql")(graphqlEndpoint) 32 | graphqlEndpoint = makeLoggingGraphqlEndpoint(graphqlLogger)(graphqlEndpoint) 33 | } 34 | 35 | graphqlHandler := kithttp.NewServer( 36 | graphqlEndpoint, 37 | decodeGraphqlRequest, 38 | encodeResponse, 39 | opts..., 40 | ) 41 | 42 | return graphqlHandler 43 | } 44 | 45 | // TODO(nicolai): alias graphqlRequest = handler.RequestOptions ? 46 | // Maybe we need Go 1.9 for this? 47 | // type graphqlRequest struct { 48 | // query string 49 | // } 50 | 51 | func makeGraphqlEndpoint(s Service) endpoint.Endpoint { 52 | return func(ctx context.Context, request interface{}) (interface{}, error) { 53 | // TODO(nicolai): Is this the right place to do this? 54 | req := request.(*handler.RequestOptions) 55 | res := s.Do(ctx, req) 56 | return res, nil 57 | } 58 | } 59 | 60 | // TODO(nicolai): put in logging.go ? Or is this endpoint stuff? 61 | // TODO(nicolai): Log more? 62 | func makeLoggingGraphqlEndpoint(logger log.Logger) endpoint.Middleware { 63 | return func(next endpoint.Endpoint) endpoint.Endpoint { 64 | return func(ctx context.Context, request interface{}) (response interface{}, err error) { 65 | defer func(begin time.Time) { 66 | logger.Log("error", err, "took", time.Since(begin)) 67 | }(time.Now()) 68 | return next(ctx, request) 69 | } 70 | } 71 | } 72 | 73 | // TODO(nicolai): This is unused. 74 | var errBadRequest = errors.New("bad request") 75 | 76 | // TODO(nicolai): Don't return 500 Internal Server Error on bad request 77 | // TODO(nicolai): Figure out how narqo gets "Decode: bad request" 78 | func decodeGraphqlRequest(_ context.Context, req *http.Request) (interface{}, error) { 79 | return handler.NewRequestOptions(req), nil 80 | } 81 | 82 | func encodeResponse(ctx context.Context, rw http.ResponseWriter, response interface{}) error { 83 | if e, ok := response.(errorer); ok && e.error() != nil { 84 | encodeError(ctx, e.error(), rw) 85 | return nil 86 | } 87 | rw.Header().Set("Content-Type", "application/json; charset=utf-8") 88 | rw.WriteHeader(http.StatusOK) 89 | return json.NewEncoder(rw).Encode(response) 90 | } 91 | 92 | type errorer interface { 93 | error() error 94 | } 95 | 96 | func encodeError(_ context.Context, err error, rw http.ResponseWriter) { 97 | rw.Header().Set("Content-Type", "application/json; charset= utf-8") 98 | // TODO(nicolai): We should be able to be more granular here. 99 | // Everything isn't an internal server error. 100 | rw.WriteHeader(http.StatusInternalServerError) 101 | json.NewEncoder(rw).Encode(map[string]interface{}{ 102 | "error": err.Error(), 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /models/root.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/nicolaiskogheim/go-kit-graphql-todo/auth" 7 | "github.com/nicolaiskogheim/go-kit-graphql-todo/schema" 8 | "github.com/nicolaiskogheim/go-kit-graphql-todo/todo" 9 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | var _ schema.QueryInterface = (*Root)(nil) 14 | 15 | var _ schema.MutationInterface = (*Root)(nil) 16 | 17 | type Root struct { 18 | TodoService todo.Service 19 | UserService user.Service 20 | } 21 | 22 | // TodosQuery ... 23 | func (root Root) TodosQuery( 24 | ctx context.Context, 25 | ) ([]schema.TodoInterface, error) { 26 | var todos []schema.TodoInterface 27 | for _, val := range root.TodoService.FindAll() { 28 | todos = append(todos, Todo{ 29 | source: *val, 30 | UserService: root.UserService, 31 | }) 32 | } 33 | return todos, nil 34 | } 35 | 36 | // TodoQuery resolves todo( id: ID! ) 37 | func (root Root) TodoQuery( 38 | ctx context.Context, 39 | id string, 40 | ) (schema.TodoInterface, error) { 41 | todo, err := root.TodoService.Find(todo.TodoID(id)) 42 | 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | t := Todo{ 48 | source: *todo, 49 | UserService: root.UserService, 50 | } 51 | 52 | return t, nil 53 | } 54 | 55 | // UsersQuery resolves users() 56 | func (root Root) UsersQuery( 57 | ctx context.Context, 58 | ) ([]schema.UserInterface, error) { 59 | var users []schema.UserInterface 60 | for _, val := range root.UserService.FindAll() { 61 | users = append(users, User{ 62 | source: *val, 63 | }) 64 | } 65 | 66 | return users, nil 67 | } 68 | 69 | // UserQuery resolves user( id: ID ) 70 | func (root Root) UserQuery( 71 | ctx context.Context, 72 | id string, 73 | ) (schema.UserInterface, error) { 74 | user, err := root.UserService.Find(user.UserID(id)) 75 | 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | u := User{ 81 | source: *user, 82 | } 83 | 84 | return u, nil 85 | } 86 | 87 | // AddTodoMutation resolves addTodo( text: String!, done: Boolean = false ) 88 | func (root Root) AddTodoMutation( 89 | ctx context.Context, 90 | text string, 91 | done bool, 92 | owner string, 93 | ) (schema.TodoInterface, error) { 94 | todo := todo.New( 95 | todo.NextTodoID(), 96 | todo.TodoText(text), 97 | todo.TodoDone(done), 98 | user.UserID(owner), 99 | ) 100 | err := root.TodoService.Add(todo) 101 | 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | return Todo{source: *todo, UserService: root.UserService}, nil 107 | } 108 | 109 | // ToggleTodoMutation resolves toggleTodo( id: ID! ) 110 | func (root Root) ToggleTodoMutation( 111 | ctx context.Context, 112 | id string, 113 | ) (schema.TodoInterface, error) { 114 | uid := auth.Viewer(ctx) 115 | user, err := root.UserService.Find(user.UserID(*uid)) 116 | 117 | if err != nil { 118 | return nil, errors.New("user not found") 119 | } 120 | 121 | todo, err := root.TodoService.Toggle(*user, todo.TodoID(id)) 122 | 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | return Todo{source: *todo, UserService: root.UserService}, nil 128 | } 129 | 130 | // DeleteTodoMutation resolves deleteTodo( id: ID! ) 131 | func (root Root) DeleteTodoMutation( 132 | ctx context.Context, 133 | id string, 134 | ) (schema.TodoInterface, error) { 135 | todo, err := root.TodoService.Remove(todo.TodoID(id)) 136 | 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | return Todo{source: *todo, UserService: root.UserService}, nil 142 | } 143 | 144 | // AddUserMutation resolves addUser( name: String!, email: String!, password: String! ) 145 | func (root Root) AddUserMutation( 146 | ctx context.Context, 147 | name string, 148 | email string, 149 | password string, 150 | ) (schema.UserInterface, error) { 151 | user := user.New( 152 | user.NextUserID(), 153 | user.UserName(name), 154 | user.UserEmail(email), 155 | user.UserPassword(password), 156 | ) 157 | 158 | err := root.UserService.Add(user) 159 | 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return User{source: *user}, nil 165 | } 166 | 167 | func (root Root) ViewerQuery( 168 | ctx context.Context, 169 | ) (schema.UserInterface, error) { 170 | id := auth.Viewer(ctx) 171 | 172 | if id == nil { 173 | return nil, nil 174 | } 175 | 176 | user, err := root.UserService.Find(user.UserID(id.ToString())) 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | return User{source: *user}, nil 182 | } 183 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate granate 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "syscall" 12 | 13 | "github.com/go-kit/kit/log" 14 | kitprometheus "github.com/go-kit/kit/metrics/prometheus" 15 | kithttp "github.com/go-kit/kit/transport/http" 16 | goredis "github.com/go-redis/redis" 17 | stdprometheus "github.com/prometheus/client_golang/prometheus" 18 | 19 | "github.com/nicolaiskogheim/go-kit-graphql-todo/auth" 20 | "github.com/nicolaiskogheim/go-kit-graphql-todo/graphql" 21 | "github.com/nicolaiskogheim/go-kit-graphql-todo/inmem" 22 | "github.com/nicolaiskogheim/go-kit-graphql-todo/models" 23 | "github.com/nicolaiskogheim/go-kit-graphql-todo/redis" 24 | "github.com/nicolaiskogheim/go-kit-graphql-todo/schema" 25 | "github.com/nicolaiskogheim/go-kit-graphql-todo/session" 26 | "github.com/nicolaiskogheim/go-kit-graphql-todo/todo" 27 | "github.com/nicolaiskogheim/go-kit-graphql-todo/user" 28 | ) 29 | 30 | const ( 31 | defaultPort = "8080" 32 | defaultDebugPort = "1337" 33 | ) 34 | 35 | func main() { 36 | var ( 37 | port = envString("PORT", defaultPort) 38 | // TODO(nicolai): This will be used with a /metrics endpoint 39 | // debugPort = envString("DEBUG_PORT", defaultDebugPort) 40 | httpAddr = flag.String("http.addr", ":"+port, "HTTP listen address") 41 | ) 42 | 43 | var logger log.Logger 44 | { 45 | logger = log.NewLogfmtLogger(os.Stderr) 46 | logger = &serializedLogger{Logger: logger} 47 | logger = log.With(logger, 48 | "ts", log.DefaultTimestampUTC, 49 | "caller", log.DefaultCaller, 50 | ) 51 | } 52 | 53 | redisClient := goredis.NewClient(&goredis.Options{ 54 | Addr: "localhost:6379", 55 | Password: "", // no password set 56 | DB: 0, // use default DB 57 | }) 58 | 59 | var ( 60 | todos = inmem.NewTodoRepository() 61 | users = inmem.NewUserRepository() 62 | sessions = redis.NewSessionRepository(*redisClient) 63 | ) 64 | 65 | fieldKeys := []string{"method"} 66 | 67 | var todoService todo.Service 68 | { 69 | todoService = todo.NewService(todos) 70 | todoService = todo.NewLoggingService(logger, todoService) 71 | todoService = todo.NewInstrumentingService( 72 | kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{ 73 | Namespace: "api", 74 | Subsystem: "todo_service", 75 | Name: "request_count", 76 | Help: "Number of requests received.", 77 | }, fieldKeys), 78 | kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{ 79 | Namespace: "api", 80 | Subsystem: "todo_service", 81 | Name: "request_latency_microseconds", 82 | Help: "Total duration of requests in microseconds.", 83 | }, fieldKeys), 84 | todoService, 85 | ) 86 | } 87 | 88 | var userService user.Service 89 | { 90 | userService = user.NewService(users) 91 | userService = user.NewLoggingService(logger, userService) 92 | userService = user.NewInstrumentingService( 93 | kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{ 94 | Namespace: "api", 95 | Subsystem: "user_service", 96 | Name: "request_count", 97 | Help: "Number of requests received.", 98 | }, fieldKeys), 99 | kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{ 100 | Namespace: "api", 101 | Subsystem: "user_service", 102 | Name: "request_latency_microseconds", 103 | Help: "Total duration of requests in microseconds.", 104 | }, fieldKeys), 105 | userService, 106 | ) 107 | } 108 | 109 | var sessionService session.Service 110 | { 111 | sessionService = session.NewService(sessions) 112 | } 113 | _ = sessionService 114 | 115 | var authService auth.Service 116 | { 117 | authService = auth.NewService(sessionService, userService) 118 | } 119 | 120 | var gqls graphql.Service 121 | { 122 | root := models.Root{TodoService: todoService, UserService: userService} 123 | schema.Init(schema.ProviderConfig{ 124 | Query: root, 125 | Mutation: root, 126 | }) 127 | 128 | gqls = graphql.NewService(schema.Schema()) 129 | gqls = graphql.NewLoggingService(logger, gqls) 130 | gqls = graphql.NewInstrumentingService( 131 | kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{ 132 | Namespace: "api", 133 | Subsystem: "graphql_service", 134 | Name: "request_count", 135 | Help: "Number of requests received.", 136 | }, fieldKeys), 137 | kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{ 138 | Namespace: "api", 139 | Subsystem: "graphql_service", 140 | Name: "request_latency_microseconds", 141 | Help: "Total duration of requests in microseconds.", 142 | }, fieldKeys), 143 | gqls, 144 | ) 145 | } 146 | 147 | httpLogger := log.With(logger, "component", "http") 148 | 149 | mux := http.NewServeMux() 150 | mux.Handle("/auth", auth.MakeHandler(authService, httpLogger)) 151 | mux.Handle("/graphql", graphql.MakeHandler(gqls, httpLogger, 152 | kithttp.ServerBefore(authService.Authenticate))) 153 | mux.Handle("/", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 154 | rw.Write(graphiql) 155 | })) 156 | 157 | http.Handle("/", accessControl(mux)) 158 | 159 | http.Handle("/metrics", stdprometheus.Handler()) 160 | 161 | errc := make(chan error, 2) 162 | go func() { 163 | c := make(chan os.Signal) 164 | signal.Notify(c, syscall.SIGINT) 165 | errc <- fmt.Errorf("%s", <-c) 166 | }() 167 | 168 | // TODO(nicolai): narqo debug listener 169 | 170 | go func() { 171 | logger.Log("transport", "http", "address", httpAddr, "msg", "listening") 172 | errc <- http.ListenAndServe(*httpAddr, nil) 173 | }() 174 | 175 | logger.Log("terminated", <-errc) 176 | } 177 | 178 | func accessControl(h http.Handler) http.Handler { 179 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 180 | w.Header().Set("Access-Control-Allow-Origin", "*") 181 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 182 | w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type") 183 | 184 | if r.Method == "OPTIONS" { 185 | return 186 | } 187 | 188 | h.ServeHTTP(w, r) 189 | }) 190 | } 191 | 192 | func envString(varName, fallback string) string { 193 | value := os.Getenv(varName) 194 | if value == "" { 195 | return fallback 196 | } 197 | return value 198 | } 199 | 200 | type serializedLogger struct { 201 | mtx sync.Mutex 202 | log.Logger 203 | } 204 | 205 | func (l *serializedLogger) Log(keyvals ...interface{}) error { 206 | l.mtx.Lock() 207 | defer l.mtx.Unlock() 208 | return l.Logger.Log(keyvals...) 209 | } 210 | 211 | var graphiql = []byte(` 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 |
Loading...
223 | 245 | 246 | 247 | `) 248 | --------------------------------------------------------------------------------