User creation, DB transactions, createdb flag changes.
This commit is contained in:
parent
e1685bd32b
commit
c0b54d821e
11 changed files with 213 additions and 9 deletions
|
@ -19,6 +19,7 @@ var (
|
||||||
func Handler() *mux.Router {
|
func Handler() *mux.Router {
|
||||||
m := router.API()
|
m := router.API()
|
||||||
m.Get(router.User).Handler(handler(serveUser))
|
m.Get(router.User).Handler(handler(serveUser))
|
||||||
|
m.Get(router.CreateUser).Handler(handler(serveCreateUser))
|
||||||
m.Get(router.Users).Handler(handler(serveUsers))
|
m.Get(router.Users).Handler(handler(serveUsers))
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
19
api/users.go
19
api/users.go
|
@ -1,6 +1,7 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -24,6 +25,24 @@ func serveUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
return writeJSON(w, user)
|
return writeJSON(w, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveCreateUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var user models.User
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := store.Users.Create(&user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeJSON(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
func serveUsers(w http.ResponseWriter, r *http.Request) error {
|
func serveUsers(w http.ResponseWriter, r *http.Request) error {
|
||||||
var opt models.UserListOptions
|
var opt models.UserListOptions
|
||||||
if err := schemaDecoder.Decode(&opt, r.URL.Query()); err != nil {
|
if err := schemaDecoder.Decode(&opt, r.URL.Query()); err != nil {
|
||||||
|
|
|
@ -63,7 +63,7 @@ type subcmd struct {
|
||||||
|
|
||||||
var subcmds = []subcmd{
|
var subcmds = []subcmd{
|
||||||
{"serve", "start web server", serveCmd},
|
{"serve", "start web server", serveCmd},
|
||||||
{"create-db", "create the database schema", createDBCmd},
|
{"createdb", "create the database schema", createDBCmd},
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveCmd(args []string) {
|
func serveCmd(args []string) {
|
||||||
|
@ -98,9 +98,10 @@ The options are:
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDBCmd(args []string) {
|
func createDBCmd(args []string) {
|
||||||
fs := flag.NewFlagSet("create-db", flag.ExitOnError)
|
fs := flag.NewFlagSet("createdb", flag.ExitOnError)
|
||||||
|
drop := fs.Bool("drop", false, "drop DB before creating")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(os.Stderr, `usage: bactdb create-db [options]
|
fmt.Fprintln(os.Stderr, `usage: bactdb createdb [options]
|
||||||
|
|
||||||
Creates the necessary DB schema.
|
Creates the necessary DB schema.
|
||||||
|
|
||||||
|
@ -116,5 +117,8 @@ The options are:
|
||||||
}
|
}
|
||||||
|
|
||||||
datastore.Connect()
|
datastore.Connect()
|
||||||
|
if *drop {
|
||||||
|
datastore.Drop()
|
||||||
|
}
|
||||||
datastore.Create()
|
datastore.Create()
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,3 +22,9 @@ func NewDatastore(dbh modl.SqlExecutor) *Datastore {
|
||||||
d.Users = &usersStore{d}
|
d.Users = &usersStore{d}
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewMockDatastore() *Datastore {
|
||||||
|
return &Datastore{
|
||||||
|
Users: &models.MockUsersService{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -51,3 +51,35 @@ func Drop() {
|
||||||
// TODO(mrd): raise errors.
|
// TODO(mrd): raise errors.
|
||||||
DB.DropTables()
|
DB.DropTables()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transact calls fn in a DB transaction. If dbh is a transaction, then it just calls
|
||||||
|
// the function. Otherwise, it begins a transaction, rolling back on failure and
|
||||||
|
// committing on success.
|
||||||
|
func transact(dbh modl.SqlExecutor, fn func(fbh modl.SqlExecutor) error) error {
|
||||||
|
var sharedTx bool
|
||||||
|
tx, sharedTx := dbh.(*modl.Transaction)
|
||||||
|
if !sharedTx {
|
||||||
|
var err error
|
||||||
|
tx, err = dbh.(*modl.DbMap).Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sharedTx {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
package datastore
|
package datastore
|
||||||
|
|
||||||
import "github.com/thermokarst/bactdb/models"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/modl"
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
DB.AddTableWithName(models.User{}, "users").SetKeys(true, "Id")
|
DB.AddTableWithName(models.User{}, "users").SetKeys(true, "Id")
|
||||||
|
@ -24,6 +32,46 @@ func (s *usersStore) Get(id int64) (*models.User, error) {
|
||||||
return users[0], nil
|
return users[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *usersStore) Create(user *models.User) (bool, error) {
|
||||||
|
retries := 3
|
||||||
|
var wantRetry bool
|
||||||
|
|
||||||
|
retry:
|
||||||
|
retries--
|
||||||
|
wantRetry = false
|
||||||
|
if retries == 0 {
|
||||||
|
return false, fmt.Errorf("failed to create user with username %q after retrying", user.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created bool
|
||||||
|
err := transact(s.dbh, func(tx modl.SqlExecutor) error {
|
||||||
|
var existing []*models.User
|
||||||
|
if err := tx.Select(&existing, `SELECT * FROM users WHERE username=$1 LIMIT 1;`, user.UserName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
*user = *existing[0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Insert(user); err != nil {
|
||||||
|
if strings.Contains(err.Error(), `violates unique constraint "username_idx"`) {
|
||||||
|
time.Sleep(time.Duration(rand.Intn(75)) * time.Millisecond)
|
||||||
|
wantRetry = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
created = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if wantRetry {
|
||||||
|
goto retry
|
||||||
|
}
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *usersStore) List(opt *models.UserListOptions) ([]*models.User, error) {
|
func (s *usersStore) List(opt *models.UserListOptions) ([]*models.User, error) {
|
||||||
if opt == nil {
|
if opt == nil {
|
||||||
opt = &models.UserListOptions{}
|
opt = &models.UserListOptions{}
|
||||||
|
|
|
@ -12,6 +12,7 @@ func TestUsersStore_Get_db(t *testing.T) {
|
||||||
|
|
||||||
tx, _ := DB.Begin()
|
tx, _ := DB.Begin()
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Test on a clean database
|
// Test on a clean database
|
||||||
tx.Exec(`DELETE FROM users;`)
|
tx.Exec(`DELETE FROM users;`)
|
||||||
if err := tx.Insert(want); err != nil {
|
if err := tx.Insert(want); err != nil {
|
||||||
|
@ -30,10 +31,32 @@ func TestUsersStore_Get_db(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsersStore_Create_db(t *testing.T) {
|
||||||
|
user := &models.User{Id: 1, UserName: "Test User"}
|
||||||
|
|
||||||
|
tx, _ := DB.Begin()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Test on a clean database
|
||||||
|
tx.Exec(`DELETE FROM users;`)
|
||||||
|
|
||||||
|
d := NewDatastore(tx)
|
||||||
|
created, err := d.Users.Create(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !created {
|
||||||
|
t.Error("!created")
|
||||||
|
}
|
||||||
|
if user.Id == 0 {
|
||||||
|
t.Error("want nonzero user.Id after submitting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUsersStore_List_db(t *testing.T) {
|
func TestUsersStore_List_db(t *testing.T) {
|
||||||
want := []*models.User{{Id: 1, UserName: "Test User"}}
|
want := []*models.User{{Id: 1, UserName: "Test User"}}
|
||||||
|
|
||||||
// tx := DBH
|
|
||||||
tx, _ := DB.Begin()
|
tx, _ := DB.Begin()
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -24,6 +25,9 @@ type UsersService interface {
|
||||||
|
|
||||||
// List all users.
|
// List all users.
|
||||||
List(opt *UserListOptions) ([]*User, error)
|
List(opt *UserListOptions) ([]*User, error)
|
||||||
|
|
||||||
|
// Create a new user. The newly created user's ID is written to user.Id
|
||||||
|
Create(user *User) (created bool, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -58,6 +62,25 @@ func (s *usersService) Get(id int64) (*User, error) {
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *usersService) Create(user *User) (bool, error) {
|
||||||
|
url, err := s.client.url(router.CreateUser, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.client.NewRequest("POST", url.String(), user)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req, &user)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode == http.StatusCreated, nil
|
||||||
|
}
|
||||||
|
|
||||||
type UserListOptions struct {
|
type UserListOptions struct {
|
||||||
ListOptions
|
ListOptions
|
||||||
}
|
}
|
||||||
|
@ -83,10 +106,13 @@ func (s *usersService) List(opt *UserListOptions) ([]*User, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockUsersService struct {
|
type MockUsersService struct {
|
||||||
Get_ func(id int64) (*User, error)
|
Get_ func(id int64) (*User, error)
|
||||||
List_ func(opt *UserListOptions) ([]*User, error)
|
List_ func(opt *UserListOptions) ([]*User, error)
|
||||||
|
Create_ func(post *User) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ UsersService = &MockUsersService{}
|
||||||
|
|
||||||
func (s *MockUsersService) Get(id int64) (*User, error) {
|
func (s *MockUsersService) Get(id int64) (*User, error) {
|
||||||
if s.Get_ == nil {
|
if s.Get_ == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
@ -94,6 +120,13 @@ func (s *MockUsersService) Get(id int64) (*User, error) {
|
||||||
return s.Get_(id)
|
return s.Get_(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MockUsersService) Create(user *User) (bool, error) {
|
||||||
|
if s.Create_ == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return s.Create_(user)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MockUsersService) List(opt *UserListOptions) ([]*User, error) {
|
func (s *MockUsersService) List(opt *UserListOptions) ([]*User, error) {
|
||||||
if s.List_ == nil {
|
if s.List_ == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
|
@ -38,6 +38,42 @@ func TestUsersService_Get(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsersService_Create(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
want := &User{Id: 1, UserName: "Test User"}
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
mux.HandleFunc(urlPath(t, router.CreateUser, nil), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
testMethod(t, r, "POST")
|
||||||
|
testBody(t, r, `{"id":1,"user_name":"Test User","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z"}`+"\n")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
user := &User{Id: 1, UserName: "Test User"}
|
||||||
|
created, err := client.Users.Create(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Users.Create returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !created {
|
||||||
|
t.Error("!created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Fatal("!called")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt)
|
||||||
|
if !reflect.DeepEqual(user, want) {
|
||||||
|
t.Errorf("Users.Create returned %+v, want %+v", user, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUsersService_List(t *testing.T) {
|
func TestUsersService_List(t *testing.T) {
|
||||||
setup()
|
setup()
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
|
@ -5,6 +5,7 @@ import "github.com/gorilla/mux"
|
||||||
func API() *mux.Router {
|
func API() *mux.Router {
|
||||||
m := mux.NewRouter()
|
m := mux.NewRouter()
|
||||||
m.Path("/users").Methods("GET").Name(Users)
|
m.Path("/users").Methods("GET").Name(Users)
|
||||||
|
m.Path("/users").Methods("POST").Name(CreateUser)
|
||||||
m.Path("/users/{Id:.+}").Methods("GET").Name(User)
|
m.Path("/users/{Id:.+}").Methods("GET").Name(User)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package router
|
package router
|
||||||
|
|
||||||
const (
|
const (
|
||||||
User = "user"
|
User = "user"
|
||||||
Users = "users"
|
CreateUser = "user:create"
|
||||||
|
Users = "users"
|
||||||
)
|
)
|
||||||
|
|
Reference in a new issue