├── .gitignore ├── migrations ├── 20190823004356_create_users.down.sql └── 20190823004356_create_users.up.sql ├── internal └── app │ ├── store │ ├── store.go │ ├── errors.go │ ├── repository.go │ ├── sqlstore │ │ ├── store_test.go │ │ ├── store.go │ │ ├── testing.go │ │ ├── userrepository_test.go │ │ └── userrepository.go │ └── teststore │ │ ├── store.go │ │ ├── userrepository.go │ │ └── userrepository_test.go │ ├── model │ ├── testing.go │ ├── validations.go │ ├── user.go │ └── user_test.go │ └── apiserver │ ├── responsewriter.go │ ├── config.go │ ├── apiserver.go │ ├── server_internal_test.go │ └── server.go ├── Makefile ├── configs └── apiserver.toml ├── go.mod ├── cmd └── apiserver │ └── main.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /apiserver 2 | -------------------------------------------------------------------------------- /migrations/20190823004356_create_users.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users; 2 | -------------------------------------------------------------------------------- /internal/app/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | // Store ... 4 | type Store interface { 5 | User() UserRepository 6 | } 7 | -------------------------------------------------------------------------------- /internal/app/store/errors.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrRecordNotFound ... 7 | ErrRecordNotFound = errors.New("record not found") 8 | ) 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | go build -v ./cmd/apiserver 4 | 5 | .PHONY: test 6 | test: 7 | go test -v -race -timeout 30s ./... 8 | 9 | .DEFAULT_GOAL := build 10 | -------------------------------------------------------------------------------- /migrations/20190823004356_create_users.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id bigserial not null primary key, 3 | email varchar not null unique, 4 | encrypted_password varchar not null 5 | ); 6 | -------------------------------------------------------------------------------- /internal/app/model/testing.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "testing" 4 | 5 | // TestUser ... 6 | func TestUser(t *testing.T) *User { 7 | t.Helper() 8 | 9 | return &User{ 10 | Email: "user@example.org", 11 | Password: "password", 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /configs/apiserver.toml: -------------------------------------------------------------------------------- 1 | bind_addr = ":8080" 2 | log_level = "debug" 3 | database_url = "host=localhost dbname=restapi_dev sslmode=disable" 4 | session_key = "8109612acafb4ae0ff34f5f1fa549577f4ca3a4a294f559498c111cc7d92973e5dde4eb64f086b49e063708705338f29b662047c09c850f5bb21da65f37036b4" 5 | -------------------------------------------------------------------------------- /internal/app/store/repository.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "github.com/gopherschool/http-rest-api/internal/app/model" 4 | 5 | // UserRepository ... 6 | type UserRepository interface { 7 | Create(*model.User) error 8 | Find(int) (*model.User, error) 9 | FindByEmail(string) (*model.User, error) 10 | } 11 | -------------------------------------------------------------------------------- /internal/app/apiserver/responsewriter.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import "net/http" 4 | 5 | type responseWriter struct { 6 | http.ResponseWriter 7 | code int 8 | } 9 | 10 | func (w *responseWriter) WriteHeader(statusCode int) { 11 | w.code = statusCode 12 | w.ResponseWriter.WriteHeader(statusCode) 13 | } 14 | -------------------------------------------------------------------------------- /internal/app/model/validations.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import validation "github.com/go-ozzo/ozzo-validation" 4 | 5 | func requiredIf(cond bool) validation.RuleFunc { 6 | return func(value interface{}) error { 7 | if cond { 8 | return validation.Validate(value, validation.Required) 9 | } 10 | 11 | return nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/app/store/sqlstore/store_test.go: -------------------------------------------------------------------------------- 1 | package sqlstore_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | databaseURL string 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | databaseURL = os.Getenv("DATABASE_URL") 14 | if databaseURL == "" { 15 | databaseURL = "host=localhost dbname=restapi_test sslmode=disable" 16 | } 17 | 18 | os.Exit(m.Run()) 19 | } 20 | -------------------------------------------------------------------------------- /internal/app/apiserver/config.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | // Config ... 4 | type Config struct { 5 | BindAddr string `toml:"bind_addr"` 6 | LogLevel string `toml:"log_level"` 7 | DatabaseURL string `toml:"database_url"` 8 | SessionKey string `toml:"session_key"` 9 | } 10 | 11 | // NewConfig ... 12 | func NewConfig() *Config { 13 | return &Config{ 14 | BindAddr: ":8080", 15 | LogLevel: "debug", 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gopherschool/http-rest-api 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect 8 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible 9 | github.com/google/uuid v1.1.1 10 | github.com/gorilla/handlers v1.4.2 11 | github.com/gorilla/mux v1.7.3 12 | github.com/gorilla/securecookie v1.1.1 13 | github.com/gorilla/sessions v1.2.0 14 | github.com/lib/pq v1.2.0 15 | github.com/sirupsen/logrus v1.4.2 16 | github.com/stretchr/testify v1.3.0 17 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 18 | ) 19 | -------------------------------------------------------------------------------- /internal/app/store/sqlstore/store.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/gopherschool/http-rest-api/internal/app/store" 7 | ) 8 | 9 | // Store ... 10 | type Store struct { 11 | db *sql.DB 12 | userRepository *UserRepository 13 | } 14 | 15 | // New ... 16 | func New(db *sql.DB) *Store { 17 | return &Store{ 18 | db: db, 19 | } 20 | } 21 | 22 | // User ... 23 | func (s *Store) User() store.UserRepository { 24 | if s.userRepository != nil { 25 | return s.userRepository 26 | } 27 | 28 | s.userRepository = &UserRepository{ 29 | store: s, 30 | } 31 | 32 | return s.userRepository 33 | } 34 | -------------------------------------------------------------------------------- /cmd/apiserver/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/BurntSushi/toml" 8 | 9 | "github.com/gopherschool/http-rest-api/internal/app/apiserver" 10 | ) 11 | 12 | var ( 13 | configPath string 14 | ) 15 | 16 | func init() { 17 | flag.StringVar(&configPath, "config-path", "configs/apiserver.toml", "path to config file") 18 | } 19 | 20 | func main() { 21 | flag.Parse() 22 | 23 | config := apiserver.NewConfig() 24 | _, err := toml.DecodeFile(configPath, config) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | if err := apiserver.Start(config); err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/app/store/teststore/store.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "github.com/gopherschool/http-rest-api/internal/app/model" 5 | "github.com/gopherschool/http-rest-api/internal/app/store" 6 | ) 7 | 8 | // Store ... 9 | type Store struct { 10 | userRepository *UserRepository 11 | } 12 | 13 | // New ... 14 | func New() *Store { 15 | return &Store{} 16 | } 17 | 18 | // User ... 19 | func (s *Store) User() store.UserRepository { 20 | if s.userRepository != nil { 21 | return s.userRepository 22 | } 23 | 24 | s.userRepository = &UserRepository{ 25 | store: s, 26 | users: make(map[int]*model.User), 27 | } 28 | 29 | return s.userRepository 30 | } 31 | -------------------------------------------------------------------------------- /internal/app/store/sqlstore/testing.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | _ "github.com/lib/pq" // ... 10 | ) 11 | 12 | // TestDB ... 13 | func TestDB(t *testing.T, databaseURL string) (*sql.DB, func(...string)) { 14 | t.Helper() 15 | 16 | db, err := sql.Open("postgres", databaseURL) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | if err := db.Ping(); err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | return db, func(tables ...string) { 26 | if len(tables) > 0 { 27 | db.Exec(fmt.Sprintf("TRUNCATE %s CASCADE", strings.Join(tables, ", "))) 28 | } 29 | 30 | db.Close() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/app/apiserver/apiserver.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import ( 4 | "database/sql" 5 | "net/http" 6 | 7 | "github.com/gopherschool/http-rest-api/internal/app/store/sqlstore" 8 | "github.com/gorilla/sessions" 9 | _ "github.com/lib/pq" // ... 10 | ) 11 | 12 | // Start ... 13 | func Start(config *Config) error { 14 | db, err := newDB(config.DatabaseURL) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | defer db.Close() 20 | store := sqlstore.New(db) 21 | sessionStore := sessions.NewCookieStore([]byte(config.SessionKey)) 22 | srv := newServer(store, sessionStore) 23 | 24 | return http.ListenAndServe(config.BindAddr, srv) 25 | } 26 | 27 | func newDB(dbURL string) (*sql.DB, error) { 28 | db, err := sql.Open("postgres", dbURL) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | if err := db.Ping(); err != nil { 34 | return nil, err 35 | } 36 | 37 | return db, nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/app/store/teststore/userrepository.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "github.com/gopherschool/http-rest-api/internal/app/model" 5 | "github.com/gopherschool/http-rest-api/internal/app/store" 6 | ) 7 | 8 | // UserRepository ... 9 | type UserRepository struct { 10 | store *Store 11 | users map[int]*model.User 12 | } 13 | 14 | // Create ... 15 | func (r *UserRepository) Create(u *model.User) error { 16 | if err := u.Validate(); err != nil { 17 | return err 18 | } 19 | 20 | if err := u.BeforeCreate(); err != nil { 21 | return err 22 | } 23 | 24 | u.ID = len(r.users) + 1 25 | r.users[u.ID] = u 26 | 27 | return nil 28 | } 29 | 30 | // Find ... 31 | func (r *UserRepository) Find(id int) (*model.User, error) { 32 | u, ok := r.users[id] 33 | if !ok { 34 | return nil, store.ErrRecordNotFound 35 | } 36 | 37 | return u, nil 38 | } 39 | 40 | // FindByEmail ... 41 | func (r *UserRepository) FindByEmail(email string) (*model.User, error) { 42 | for _, u := range r.users { 43 | if u.Email == email { 44 | return u, nil 45 | } 46 | } 47 | 48 | return nil, store.ErrRecordNotFound 49 | } 50 | -------------------------------------------------------------------------------- /internal/app/store/teststore/userrepository_test.go: -------------------------------------------------------------------------------- 1 | package teststore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gopherschool/http-rest-api/internal/app/model" 7 | "github.com/gopherschool/http-rest-api/internal/app/store" 8 | "github.com/gopherschool/http-rest-api/internal/app/store/teststore" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestUserRepository_Create(t *testing.T) { 13 | s := teststore.New() 14 | u := model.TestUser(t) 15 | assert.NoError(t, s.User().Create(u)) 16 | assert.NotNil(t, u.ID) 17 | } 18 | 19 | func TestUserRepository_Find(t *testing.T) { 20 | s := teststore.New() 21 | u1 := model.TestUser(t) 22 | s.User().Create(u1) 23 | u2, err := s.User().Find(u1.ID) 24 | assert.NoError(t, err) 25 | assert.NotNil(t, u2) 26 | } 27 | 28 | func TestUserRepository_FindByEmail(t *testing.T) { 29 | s := teststore.New() 30 | u1 := model.TestUser(t) 31 | _, err := s.User().FindByEmail(u1.Email) 32 | assert.EqualError(t, err, store.ErrRecordNotFound.Error()) 33 | 34 | s.User().Create(u1) 35 | u2, err := s.User().FindByEmail(u1.Email) 36 | assert.NoError(t, err) 37 | assert.NotNil(t, u2) 38 | } 39 | -------------------------------------------------------------------------------- /internal/app/store/sqlstore/userrepository_test.go: -------------------------------------------------------------------------------- 1 | package sqlstore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gopherschool/http-rest-api/internal/app/model" 7 | "github.com/gopherschool/http-rest-api/internal/app/store" 8 | "github.com/gopherschool/http-rest-api/internal/app/store/sqlstore" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestUserRepository_Create(t *testing.T) { 13 | db, teardown := sqlstore.TestDB(t, databaseURL) 14 | defer teardown("users") 15 | 16 | s := sqlstore.New(db) 17 | u := model.TestUser(t) 18 | assert.NoError(t, s.User().Create(u)) 19 | assert.NotNil(t, u.ID) 20 | } 21 | 22 | func TestUserRepository_Find(t *testing.T) { 23 | db, teardown := sqlstore.TestDB(t, databaseURL) 24 | defer teardown("users") 25 | 26 | s := sqlstore.New(db) 27 | u1 := model.TestUser(t) 28 | s.User().Create(u1) 29 | u2, err := s.User().Find(u1.ID) 30 | assert.NoError(t, err) 31 | assert.NotNil(t, u2) 32 | } 33 | 34 | func TestUserRepository_FindByEmail(t *testing.T) { 35 | db, teardown := sqlstore.TestDB(t, databaseURL) 36 | defer teardown("users") 37 | 38 | s := sqlstore.New(db) 39 | u1 := model.TestUser(t) 40 | _, err := s.User().FindByEmail(u1.Email) 41 | assert.EqualError(t, err, store.ErrRecordNotFound.Error()) 42 | 43 | s.User().Create(u1) 44 | u2, err := s.User().FindByEmail(u1.Email) 45 | assert.NoError(t, err) 46 | assert.NotNil(t, u2) 47 | } 48 | -------------------------------------------------------------------------------- /internal/app/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | validation "github.com/go-ozzo/ozzo-validation" 5 | "github.com/go-ozzo/ozzo-validation/is" 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | // User ... 10 | type User struct { 11 | ID int `json:"id"` 12 | Email string `json:"email"` 13 | Password string `json:"password,omitempty"` 14 | EncryptedPassword string `json:"-"` 15 | } 16 | 17 | // Validate ... 18 | func (u *User) Validate() error { 19 | return validation.ValidateStruct( 20 | u, 21 | validation.Field(&u.Email, validation.Required, is.Email), 22 | validation.Field(&u.Password, validation.By(requiredIf(u.EncryptedPassword == "")), validation.Length(6, 100)), 23 | ) 24 | } 25 | 26 | // BeforeCreate ... 27 | func (u *User) BeforeCreate() error { 28 | if len(u.Password) > 0 { 29 | enc, err := encryptString(u.Password) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | u.EncryptedPassword = enc 35 | } 36 | 37 | return nil 38 | } 39 | 40 | // Sanitize ... 41 | func (u *User) Sanitize() { 42 | u.Password = "" 43 | } 44 | 45 | // ComparePassword ... 46 | func (u *User) ComparePassword(password string) bool { 47 | return bcrypt.CompareHashAndPassword([]byte(u.EncryptedPassword), []byte(password)) == nil 48 | } 49 | 50 | func encryptString(s string) (string, error) { 51 | b, err := bcrypt.GenerateFromPassword([]byte(s), bcrypt.MinCost) 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | return string(b), nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/app/store/sqlstore/userrepository.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/gopherschool/http-rest-api/internal/app/model" 7 | "github.com/gopherschool/http-rest-api/internal/app/store" 8 | ) 9 | 10 | // UserRepository ... 11 | type UserRepository struct { 12 | store *Store 13 | } 14 | 15 | // Create ... 16 | func (r *UserRepository) Create(u *model.User) error { 17 | if err := u.Validate(); err != nil { 18 | return err 19 | } 20 | 21 | if err := u.BeforeCreate(); err != nil { 22 | return err 23 | } 24 | 25 | return r.store.db.QueryRow( 26 | "INSERT INTO users (email, encrypted_password) VALUES ($1, $2) RETURNING id", 27 | u.Email, 28 | u.EncryptedPassword, 29 | ).Scan(&u.ID) 30 | } 31 | 32 | // Find ... 33 | func (r *UserRepository) Find(id int) (*model.User, error) { 34 | u := &model.User{} 35 | if err := r.store.db.QueryRow( 36 | "SELECT id, email, encrypted_password FROM users WHERE id = $1", 37 | id, 38 | ).Scan( 39 | &u.ID, 40 | &u.Email, 41 | &u.EncryptedPassword, 42 | ); err != nil { 43 | if err == sql.ErrNoRows { 44 | return nil, store.ErrRecordNotFound 45 | } 46 | 47 | return nil, err 48 | } 49 | 50 | return u, nil 51 | } 52 | 53 | // FindByEmail ... 54 | func (r *UserRepository) FindByEmail(email string) (*model.User, error) { 55 | u := &model.User{} 56 | if err := r.store.db.QueryRow( 57 | "SELECT id, email, encrypted_password FROM users WHERE email = $1", 58 | email, 59 | ).Scan( 60 | &u.ID, 61 | &u.Email, 62 | &u.EncryptedPassword, 63 | ); err != nil { 64 | if err == sql.ErrNoRows { 65 | return nil, store.ErrRecordNotFound 66 | } 67 | 68 | return nil, err 69 | } 70 | 71 | return u, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/app/model/user_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gopherschool/http-rest-api/internal/app/model" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestUser_Validate(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | u func() *model.User 14 | isValid bool 15 | }{ 16 | { 17 | name: "valid", 18 | u: func() *model.User { 19 | return model.TestUser(t) 20 | }, 21 | isValid: true, 22 | }, 23 | { 24 | name: "with encrypted password", 25 | u: func() *model.User { 26 | u := model.TestUser(t) 27 | u.Password = "" 28 | u.EncryptedPassword = "encryptedpassword" 29 | 30 | return u 31 | }, 32 | isValid: true, 33 | }, 34 | { 35 | name: "empty email", 36 | u: func() *model.User { 37 | u := model.TestUser(t) 38 | u.Email = "" 39 | 40 | return u 41 | }, 42 | isValid: false, 43 | }, 44 | { 45 | name: "invalid email", 46 | u: func() *model.User { 47 | u := model.TestUser(t) 48 | u.Email = "invalid" 49 | 50 | return u 51 | }, 52 | isValid: false, 53 | }, 54 | { 55 | name: "empty password", 56 | u: func() *model.User { 57 | u := model.TestUser(t) 58 | u.Password = "" 59 | 60 | return u 61 | }, 62 | isValid: false, 63 | }, 64 | { 65 | name: "short password", 66 | u: func() *model.User { 67 | u := model.TestUser(t) 68 | u.Password = "short" 69 | 70 | return u 71 | }, 72 | isValid: false, 73 | }, 74 | } 75 | 76 | for _, tc := range testCases { 77 | t.Run(tc.name, func(t *testing.T) { 78 | if tc.isValid { 79 | assert.NoError(t, tc.u().Validate()) 80 | } else { 81 | assert.Error(t, tc.u().Validate()) 82 | } 83 | }) 84 | } 85 | } 86 | 87 | func TestUser_BeforeCreate(t *testing.T) { 88 | u := model.TestUser(t) 89 | assert.NoError(t, u.BeforeCreate()) 90 | assert.NotEmpty(t, u.EncryptedPassword) 91 | } 92 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= 4 | github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= 9 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= 10 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 11 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 12 | github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= 13 | github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= 14 | github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= 15 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 16 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 17 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 18 | github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= 19 | github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 20 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 21 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 22 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 23 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= 27 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 28 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 29 | github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= 30 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 31 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 32 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 33 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 34 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 35 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 36 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0= 37 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 38 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 39 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 40 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 41 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= 42 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 44 | -------------------------------------------------------------------------------- /internal/app/apiserver/server_internal_test.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/gorilla/securecookie" 12 | "github.com/gorilla/sessions" 13 | 14 | "github.com/gopherschool/http-rest-api/internal/app/model" 15 | 16 | "github.com/gopherschool/http-rest-api/internal/app/store/teststore" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | func TestServer_AuthenticateUser(t *testing.T) { 21 | store := teststore.New() 22 | u := model.TestUser(t) 23 | store.User().Create(u) 24 | 25 | testCases := []struct { 26 | name string 27 | cookieValue map[interface{}]interface{} 28 | expectedCode int 29 | }{ 30 | { 31 | name: "authenticated", 32 | cookieValue: map[interface{}]interface{}{ 33 | "user_id": u.ID, 34 | }, 35 | expectedCode: http.StatusOK, 36 | }, 37 | { 38 | name: "not authenticated", 39 | cookieValue: nil, 40 | expectedCode: http.StatusUnauthorized, 41 | }, 42 | } 43 | 44 | secretKey := []byte("secret") 45 | s := newServer(store, sessions.NewCookieStore(secretKey)) 46 | sc := securecookie.New(secretKey, nil) 47 | mw := s.authenticateUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | w.WriteHeader(http.StatusOK) 49 | })) 50 | 51 | for _, tc := range testCases { 52 | t.Run(tc.name, func(t *testing.T) { 53 | rec := httptest.NewRecorder() 54 | req, _ := http.NewRequest(http.MethodGet, "/", nil) 55 | cookieStr, _ := sc.Encode(sessionName, tc.cookieValue) 56 | req.Header.Set("Cookie", fmt.Sprintf("%s=%s", sessionName, cookieStr)) 57 | mw.ServeHTTP(rec, req) 58 | assert.Equal(t, tc.expectedCode, rec.Code) 59 | }) 60 | } 61 | } 62 | 63 | func TestServer_HandleUsersCreate(t *testing.T) { 64 | s := newServer(teststore.New(), sessions.NewCookieStore([]byte("secret"))) 65 | testCases := []struct { 66 | name string 67 | payload interface{} 68 | expectedCode int 69 | }{ 70 | { 71 | name: "valid", 72 | payload: map[string]interface{}{ 73 | "email": "user@example.org", 74 | "password": "secret", 75 | }, 76 | expectedCode: http.StatusCreated, 77 | }, 78 | { 79 | name: "invalid payload", 80 | payload: "invalid", 81 | expectedCode: http.StatusBadRequest, 82 | }, 83 | { 84 | name: "invalid params", 85 | payload: map[string]interface{}{ 86 | "email": "invalid", 87 | "password": "short", 88 | }, 89 | expectedCode: http.StatusUnprocessableEntity, 90 | }, 91 | } 92 | 93 | for _, tc := range testCases { 94 | t.Run(tc.name, func(t *testing.T) { 95 | b := &bytes.Buffer{} 96 | json.NewEncoder(b).Encode(tc.payload) 97 | rec := httptest.NewRecorder() 98 | req, _ := http.NewRequest(http.MethodPost, "/users", b) 99 | s.ServeHTTP(rec, req) 100 | assert.Equal(t, tc.expectedCode, rec.Code) 101 | }) 102 | } 103 | } 104 | 105 | func TestServer_HandleSessionsCreate(t *testing.T) { 106 | store := teststore.New() 107 | u := model.TestUser(t) 108 | store.User().Create(u) 109 | s := newServer(store, sessions.NewCookieStore([]byte("secret"))) 110 | testCases := []struct { 111 | name string 112 | payload interface{} 113 | expectedCode int 114 | }{ 115 | { 116 | name: "valid", 117 | payload: map[string]interface{}{ 118 | "email": u.Email, 119 | "password": u.Password, 120 | }, 121 | expectedCode: http.StatusOK, 122 | }, 123 | { 124 | name: "invalid payload", 125 | payload: "invalid", 126 | expectedCode: http.StatusBadRequest, 127 | }, 128 | { 129 | name: "invalid email", 130 | payload: map[string]interface{}{ 131 | "email": "invalid", 132 | "password": u.Password, 133 | }, 134 | expectedCode: http.StatusUnauthorized, 135 | }, 136 | { 137 | name: "invalid password", 138 | payload: map[string]interface{}{ 139 | "email": u.Email, 140 | "password": "invalid", 141 | }, 142 | expectedCode: http.StatusUnauthorized, 143 | }, 144 | } 145 | 146 | for _, tc := range testCases { 147 | t.Run(tc.name, func(t *testing.T) { 148 | b := &bytes.Buffer{} 149 | json.NewEncoder(b).Encode(tc.payload) 150 | rec := httptest.NewRecorder() 151 | req, _ := http.NewRequest(http.MethodPost, "/sessions", b) 152 | s.ServeHTTP(rec, req) 153 | assert.Equal(t, tc.expectedCode, rec.Code) 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /internal/app/apiserver/server.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | "github.com/sirupsen/logrus" 12 | 13 | "github.com/gorilla/handlers" 14 | 15 | "github.com/gopherschool/http-rest-api/internal/app/model" 16 | 17 | "github.com/gopherschool/http-rest-api/internal/app/store" 18 | "github.com/gorilla/mux" 19 | "github.com/gorilla/sessions" 20 | ) 21 | 22 | const ( 23 | sessionName = "gopherschool" 24 | ctxKeyUser ctxKey = iota 25 | ctxKeyRequestID 26 | ) 27 | 28 | var ( 29 | errIncorrectEmailOrPassword = errors.New("incorrect email or password") 30 | errNotAuthenticated = errors.New("not authenticated") 31 | ) 32 | 33 | type ctxKey int8 34 | 35 | type server struct { 36 | router *mux.Router 37 | logger *logrus.Logger 38 | store store.Store 39 | sessionStore sessions.Store 40 | } 41 | 42 | func newServer(store store.Store, sessionStore sessions.Store) *server { 43 | s := &server{ 44 | router: mux.NewRouter(), 45 | logger: logrus.New(), 46 | store: store, 47 | sessionStore: sessionStore, 48 | } 49 | 50 | s.configureRouter() 51 | 52 | return s 53 | } 54 | 55 | func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 56 | s.router.ServeHTTP(w, r) 57 | } 58 | 59 | func (s *server) configureRouter() { 60 | s.router.Use(s.setRequestID) 61 | s.router.Use(s.logRequest) 62 | s.router.Use(handlers.CORS(handlers.AllowedOrigins([]string{"*"}))) 63 | s.router.HandleFunc("/users", s.handleUsersCreate()).Methods("POST") 64 | s.router.HandleFunc("/sessions", s.handleSessionsCreate()).Methods("POST") 65 | 66 | private := s.router.PathPrefix("/private").Subrouter() 67 | private.Use(s.authenticateUser) 68 | private.HandleFunc("/whoami", s.handleWhoami()) 69 | } 70 | 71 | func (s *server) setRequestID(next http.Handler) http.Handler { 72 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 73 | id := uuid.New().String() 74 | w.Header().Set("X-Request-ID", id) 75 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id))) 76 | }) 77 | } 78 | 79 | func (s *server) logRequest(next http.Handler) http.Handler { 80 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 81 | logger := s.logger.WithFields(logrus.Fields{ 82 | "remote_addr": r.RemoteAddr, 83 | "request_id": r.Context().Value(ctxKeyRequestID), 84 | }) 85 | logger.Infof("started %s %s", r.Method, r.RequestURI) 86 | 87 | start := time.Now() 88 | rw := &responseWriter{w, http.StatusOK} 89 | next.ServeHTTP(rw, r) 90 | 91 | var level logrus.Level 92 | switch { 93 | case rw.code >= 500: 94 | level = logrus.ErrorLevel 95 | case rw.code >= 400: 96 | level = logrus.WarnLevel 97 | default: 98 | level = logrus.InfoLevel 99 | } 100 | logger.Logf( 101 | level, 102 | "completed with %d %s in %v", 103 | rw.code, 104 | http.StatusText(rw.code), 105 | time.Now().Sub(start), 106 | ) 107 | }) 108 | } 109 | 110 | func (s *server) authenticateUser(next http.Handler) http.Handler { 111 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 | session, err := s.sessionStore.Get(r, sessionName) 113 | if err != nil { 114 | s.error(w, r, http.StatusInternalServerError, err) 115 | return 116 | } 117 | 118 | id, ok := session.Values["user_id"] 119 | if !ok { 120 | s.error(w, r, http.StatusUnauthorized, errNotAuthenticated) 121 | return 122 | } 123 | 124 | u, err := s.store.User().Find(id.(int)) 125 | if err != nil { 126 | s.error(w, r, http.StatusUnauthorized, errNotAuthenticated) 127 | return 128 | } 129 | 130 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u))) 131 | }) 132 | } 133 | 134 | func (s *server) handleUsersCreate() http.HandlerFunc { 135 | type request struct { 136 | Email string `json:"email"` 137 | Password string `json:"password"` 138 | } 139 | 140 | return func(w http.ResponseWriter, r *http.Request) { 141 | req := &request{} 142 | if err := json.NewDecoder(r.Body).Decode(req); err != nil { 143 | s.error(w, r, http.StatusBadRequest, err) 144 | return 145 | } 146 | 147 | u := &model.User{ 148 | Email: req.Email, 149 | Password: req.Password, 150 | } 151 | if err := s.store.User().Create(u); err != nil { 152 | s.error(w, r, http.StatusUnprocessableEntity, err) 153 | return 154 | } 155 | 156 | u.Sanitize() 157 | s.respond(w, r, http.StatusCreated, u) 158 | } 159 | } 160 | 161 | func (s *server) handleSessionsCreate() http.HandlerFunc { 162 | type request struct { 163 | Email string `json:"email"` 164 | Password string `json:"password"` 165 | } 166 | 167 | return func(w http.ResponseWriter, r *http.Request) { 168 | req := &request{} 169 | if err := json.NewDecoder(r.Body).Decode(req); err != nil { 170 | s.error(w, r, http.StatusBadRequest, err) 171 | return 172 | } 173 | 174 | u, err := s.store.User().FindByEmail(req.Email) 175 | if err != nil || !u.ComparePassword(req.Password) { 176 | s.error(w, r, http.StatusUnauthorized, errIncorrectEmailOrPassword) 177 | return 178 | } 179 | 180 | session, err := s.sessionStore.Get(r, sessionName) 181 | if err != nil { 182 | s.error(w, r, http.StatusInternalServerError, err) 183 | return 184 | } 185 | 186 | session.Values["user_id"] = u.ID 187 | if err := s.sessionStore.Save(r, w, session); err != nil { 188 | s.error(w, r, http.StatusInternalServerError, err) 189 | return 190 | } 191 | 192 | s.respond(w, r, http.StatusOK, nil) 193 | } 194 | } 195 | 196 | func (s *server) handleWhoami() http.HandlerFunc { 197 | return func(w http.ResponseWriter, r *http.Request) { 198 | s.respond(w, r, http.StatusOK, r.Context().Value(ctxKeyUser).(*model.User)) 199 | } 200 | } 201 | 202 | func (s *server) error(w http.ResponseWriter, r *http.Request, code int, err error) { 203 | s.respond(w, r, code, map[string]string{"error": err.Error()}) 204 | } 205 | 206 | func (s *server) respond(w http.ResponseWriter, r *http.Request, code int, data interface{}) { 207 | w.WriteHeader(code) 208 | if data != nil { 209 | json.NewEncoder(w).Encode(data) 210 | } 211 | } 212 | --------------------------------------------------------------------------------