Merge pull request #1 from thermokarst/initial-architecture
Initial architecture.
This commit is contained in:
commit
10a3a604be
23 changed files with 1330 additions and 1 deletions
9
.travis.yml
Normal file
9
.travis.yml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
language: go
|
||||||
|
|
||||||
|
addons:
|
||||||
|
postgresql: 9.3
|
||||||
|
|
||||||
|
go: 1.3
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- psql -c 'create database bactdbtest;' -U postgres
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2014 Matthew Dillon
|
Copyright (c) 2014 Matthew Dillon, Sourcegraph, Inc.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
36
api/handler.go
Normal file
36
api/handler.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/schema"
|
||||||
|
"github.com/thermokarst/bactdb/datastore"
|
||||||
|
"github.com/thermokarst/bactdb/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
store = datastore.NewDatastore(nil)
|
||||||
|
schemaDecoder = schema.NewDecoder()
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler() *mux.Router {
|
||||||
|
m := router.API()
|
||||||
|
m.Get(router.User).Handler(handler(serveUser))
|
||||||
|
m.Get(router.CreateUser).Handler(handler(serveCreateUser))
|
||||||
|
m.Get(router.Users).Handler(handler(serveUsers))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler func(http.ResponseWriter, *http.Request) error
|
||||||
|
|
||||||
|
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := h(w, r)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "error: %s", err)
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
19
api/helpers.go
Normal file
19
api/helpers.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeJSON writes a JSON Content-Type header and a JSON-encoded object to
|
||||||
|
// the http.ResponseWriter.
|
||||||
|
func writeJSON(w http.ResponseWriter, v interface{}) error {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("content-type", "application/json; charset=utf-8")
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
25
api/helpers_test.go
Normal file
25
api/helpers_test.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function that normalizes structs for comparison with reflect.DeepEqual
|
||||||
|
func normalize(v interface{}) {
|
||||||
|
j, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Could not normalize object %+v due to JSON marshalling error: %s", v, err))
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(j, v)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Could not normalize object %+v due to JSON un-marshalling error: %s", v, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeepEqual(u, v interface{}) bool {
|
||||||
|
normalize(u)
|
||||||
|
normalize(v)
|
||||||
|
return reflect.DeepEqual(u, v)
|
||||||
|
}
|
43
api/server_for_test.go
Normal file
43
api/server_for_test.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/datastore"
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
serveMux.Handle("/", http.StripPrefix("/api", Handler()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
serveMux = http.NewServeMux()
|
||||||
|
httpClient = http.Client{Transport: (*muxTransport)(serveMux)}
|
||||||
|
apiClient = models.NewClient(&httpClient)
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup() {
|
||||||
|
store = datastore.NewMockDatastore()
|
||||||
|
}
|
||||||
|
|
||||||
|
type muxTransport http.ServeMux
|
||||||
|
|
||||||
|
// RoundTrip is for testing API requests. It intercepts all requests during testing
|
||||||
|
// to serve up a local/internal response.
|
||||||
|
func (t *muxTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
rw.Body = new(bytes.Buffer)
|
||||||
|
(*http.ServeMux)(t).ServeHTTP(rw, req)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: rw.Code,
|
||||||
|
Status: http.StatusText(rw.Code),
|
||||||
|
Header: rw.HeaderMap,
|
||||||
|
Body: ioutil.NopCloser(rw.Body),
|
||||||
|
ContentLength: int64(rw.Body.Len()),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
61
api/users.go
Normal file
61
api/users.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
id, err := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := store.Users.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
var opt models.UserListOptions
|
||||||
|
if err := schemaDecoder.Decode(&opt, r.URL.Query()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := store.Users.List(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if users == nil {
|
||||||
|
users = []*models.User{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeJSON(w, users)
|
||||||
|
}
|
90
api/users_test.go
Normal file
90
api/users_test.go
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUser_Get(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
|
||||||
|
wantUser := &models.User{Id: 1, UserName: "Test User"}
|
||||||
|
|
||||||
|
calledGet := false
|
||||||
|
store.Users.(*models.MockUsersService).Get_ = func(id int64) (*models.User, error) {
|
||||||
|
if id != wantUser.Id {
|
||||||
|
t.Errorf("wanted request for user %d but got %d", wantUser.Id, id)
|
||||||
|
}
|
||||||
|
calledGet = true
|
||||||
|
return wantUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gotUser, err := apiClient.Users.Get(wantUser.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !calledGet {
|
||||||
|
t.Error("!calledGet")
|
||||||
|
}
|
||||||
|
if !normalizeDeepEqual(wantUser, gotUser) {
|
||||||
|
t.Errorf("got user %+v but wanted user %+v", wantUser, gotUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_Create(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
|
||||||
|
wantUser := &models.User{Id: 1, UserName: "Test User"}
|
||||||
|
|
||||||
|
calledPost := false
|
||||||
|
store.Users.(*models.MockUsersService).Create_ = func(user *models.User) (bool, error) {
|
||||||
|
if !normalizeDeepEqual(wantUser, user) {
|
||||||
|
t.Errorf("wanted request for user %d but got %d", wantUser, user)
|
||||||
|
}
|
||||||
|
calledPost = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
success, err := apiClient.Users.Create(wantUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !calledPost {
|
||||||
|
t.Error("!calledPost")
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
t.Error("!success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_List(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
|
||||||
|
wantUsers := []*models.User{{Id: 1, UserName: "Test User"}}
|
||||||
|
wantOpt := &models.UserListOptions{ListOptions: models.ListOptions{Page: 1, PerPage: 10}}
|
||||||
|
|
||||||
|
calledList := false
|
||||||
|
store.Users.(*models.MockUsersService).List_ = func(opt *models.UserListOptions) ([]*models.User, error) {
|
||||||
|
if !normalizeDeepEqual(wantOpt, opt) {
|
||||||
|
t.Errorf("wanted options %d but got %d", wantOpt, opt)
|
||||||
|
}
|
||||||
|
calledList = true
|
||||||
|
return wantUsers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := apiClient.Users.List(wantOpt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !calledList {
|
||||||
|
t.Error("!calledList")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !normalizeDeepEqual(&wantUsers, &users) {
|
||||||
|
t.Errorf("got users %+v but wanted users %+v", users, wantUsers)
|
||||||
|
}
|
||||||
|
}
|
124
cmd/bactdb/bactdb.go
Normal file
124
cmd/bactdb/bactdb.go
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/api"
|
||||||
|
"github.com/thermokarst/bactdb/datastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, `bactdb is a database for bacteria.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
bactdb [options] command [arg...]
|
||||||
|
|
||||||
|
The commands are:
|
||||||
|
`)
|
||||||
|
for _, c := range subcmds {
|
||||||
|
fmt.Fprintf(os.Stderr, " %-24s %s\n", c.name, c.description)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, `
|
||||||
|
Use "bactdb command -h" for more information about a command.
|
||||||
|
|
||||||
|
The options are:
|
||||||
|
`)
|
||||||
|
flag.PrintDefaults()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if flag.NArg() == 0 {
|
||||||
|
flag.Usage()
|
||||||
|
}
|
||||||
|
log.SetFlags(0)
|
||||||
|
|
||||||
|
subcmd := flag.Arg(0)
|
||||||
|
for _, c := range subcmds {
|
||||||
|
if c.name == subcmd {
|
||||||
|
c.run(flag.Args()[1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown subcmd %q\n", subcmd)
|
||||||
|
fmt.Fprintln(os.Stderr, `Run "bactdb -h" for usage.`)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type subcmd struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
run func(args []string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subcmds = []subcmd{
|
||||||
|
{"serve", "start web server", serveCmd},
|
||||||
|
{"createdb", "create the database schema", createDBCmd},
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||||
|
httpAddr := flag.String("http", ":8901", "HTTP service address")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, `usage: bactdb serve [options]
|
||||||
|
|
||||||
|
Starts the web server that serves the API.
|
||||||
|
|
||||||
|
The options are:
|
||||||
|
`)
|
||||||
|
fs.PrintDefaults()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
fs.Usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
datastore.Connect()
|
||||||
|
|
||||||
|
m := http.NewServeMux()
|
||||||
|
m.Handle("/api/", http.StripPrefix("/api", api.Handler()))
|
||||||
|
|
||||||
|
log.Print("Listening on ", *httpAddr)
|
||||||
|
err := http.ListenAndServe(*httpAddr, m)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("ListenAndServe:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDBCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("createdb", flag.ExitOnError)
|
||||||
|
drop := fs.Bool("drop", false, "drop DB before creating")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, `usage: bactdb createdb [options]
|
||||||
|
|
||||||
|
Creates the necessary DB schema.
|
||||||
|
|
||||||
|
The options are:
|
||||||
|
`)
|
||||||
|
fs.PrintDefaults()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
fs.Usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
datastore.Connect()
|
||||||
|
if *drop {
|
||||||
|
datastore.Drop()
|
||||||
|
}
|
||||||
|
datastore.Create()
|
||||||
|
}
|
30
datastore/datastore.go
Normal file
30
datastore/datastore.go
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jmoiron/modl"
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A datastore access point (in PostgreSQL)
|
||||||
|
type Datastore struct {
|
||||||
|
Users models.UsersService
|
||||||
|
dbh modl.SqlExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatastore creates a new client for accessing the datastore (in PostgreSQL).
|
||||||
|
// If dbh is nil, it uses the global DB handle.
|
||||||
|
func NewDatastore(dbh modl.SqlExecutor) *Datastore {
|
||||||
|
if dbh == nil {
|
||||||
|
dbh = DBH
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &Datastore{dbh: dbh}
|
||||||
|
d.Users = &usersStore{d}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockDatastore() *Datastore {
|
||||||
|
return &Datastore{
|
||||||
|
Users: &models.MockUsersService{},
|
||||||
|
}
|
||||||
|
}
|
9
datastore/datastore_test.go
Normal file
9
datastore/datastore_test.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func normalizeTime(t ...*time.Time) {
|
||||||
|
for _, v := range t {
|
||||||
|
*v = v.In(time.UTC)
|
||||||
|
}
|
||||||
|
}
|
87
datastore/db.go
Normal file
87
datastore/db.go
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jmoiron/modl"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB is the global database
|
||||||
|
var DB = &modl.DbMap{Dialect: modl.PostgresDialect{}}
|
||||||
|
|
||||||
|
// DBH is a modl.SqlExecutor interface to DB, the global database. It is better to
|
||||||
|
// use DBH instead of DB because it prevents you from calling methods that could
|
||||||
|
// not later be wrapped in a transaction.
|
||||||
|
var DBH modl.SqlExecutor = DB
|
||||||
|
|
||||||
|
var connectOnce sync.Once
|
||||||
|
|
||||||
|
// Connect connects to the PostgreSQL database specified by the PG* environment
|
||||||
|
// variables. It calls log.Fatal if it encounters an error.
|
||||||
|
func Connect() {
|
||||||
|
connectOnce.Do(func() {
|
||||||
|
var err error
|
||||||
|
DB.Dbx, err = sqlx.Open("postgres", "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error connecting to PostgreSQL database (using PG* environment variables): ", err)
|
||||||
|
}
|
||||||
|
DB.TraceOn("[modl]", log.New(os.Stdout, "bactdb:", log.Lmicroseconds))
|
||||||
|
DB.Db = DB.Dbx.DB
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var createSQL []string
|
||||||
|
|
||||||
|
// Create the database schema. It calls log.Fatal if it encounters an error.
|
||||||
|
func Create() {
|
||||||
|
if err := DB.CreateTablesIfNotExists(); err != nil {
|
||||||
|
log.Fatal("Error creating tables: ", err)
|
||||||
|
}
|
||||||
|
for _, query := range createSQL {
|
||||||
|
if _, err := DB.Exec(query); err != nil {
|
||||||
|
log.Fatalf("Error running query %q: %s", query, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the database schema
|
||||||
|
func Drop() {
|
||||||
|
// TODO(mrd): raise errors.
|
||||||
|
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
|
||||||
|
}
|
26
datastore/db_test.go
Normal file
26
datastore/db_test.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Make sure we don't run the tests on the main DB (will destroy the data)
|
||||||
|
dbname := os.Getenv("PGDATABASE")
|
||||||
|
if dbname == "" {
|
||||||
|
dbname = "bactdbtest"
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(dbname, "test") {
|
||||||
|
dbname += "test"
|
||||||
|
}
|
||||||
|
if err := os.Setenv("PGDATABASE", dbname); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset DB
|
||||||
|
Connect()
|
||||||
|
Drop()
|
||||||
|
Create()
|
||||||
|
}
|
85
datastore/users.go
Normal file
85
datastore/users.go
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/modl"
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
DB.AddTableWithName(models.User{}, "users").SetKeys(true, "Id")
|
||||||
|
createSQL = append(createSQL,
|
||||||
|
`CREATE UNIQUE INDEX username_idx ON users (username);`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type usersStore struct {
|
||||||
|
*Datastore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *usersStore) Get(id int64) (*models.User, error) {
|
||||||
|
var users []*models.User
|
||||||
|
if err := s.dbh.Select(&users, `SELECT * FROM users WHERE id=$1;`, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, models.ErrUserNotFound
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if opt == nil {
|
||||||
|
opt = &models.UserListOptions{}
|
||||||
|
}
|
||||||
|
var users []*models.User
|
||||||
|
err := s.dbh.Select(&users, `SELECT * FROM users LIMIT $1 OFFSET $2;`, opt.PerPageOrDefault(), opt.Offset())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
81
datastore/users_test.go
Normal file
81
datastore/users_test.go
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUsersStore_Get_db(t *testing.T) {
|
||||||
|
want := &models.User{Id: 1, UserName: "Test User"}
|
||||||
|
|
||||||
|
tx, _ := DB.Begin()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Test on a clean database
|
||||||
|
tx.Exec(`DELETE FROM users;`)
|
||||||
|
if err := tx.Insert(want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := NewDatastore(tx)
|
||||||
|
user, err := d.Users.Get(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt)
|
||||||
|
if !reflect.DeepEqual(user, want) {
|
||||||
|
t.Errorf("got user %+v, want %+v", user, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
want := []*models.User{{Id: 1, UserName: "Test User"}}
|
||||||
|
|
||||||
|
tx, _ := DB.Begin()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Test on a clean database
|
||||||
|
tx.Exec(`DELETE FROM users;`)
|
||||||
|
if err := tx.Insert(want[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := NewDatastore(tx)
|
||||||
|
users, err := d.Users.List(&models.UserListOptions{ListOptions: models.ListOptions{Page: 1, PerPage: 10}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range want {
|
||||||
|
normalizeTime(&u.CreatedAt, &u.UpdatedAt, &u.DeletedAt)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(users, want) {
|
||||||
|
t.Errorf("got users %+v, want %+v", users, want)
|
||||||
|
}
|
||||||
|
}
|
189
models/client.go
Normal file
189
models/client.go
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/go-querystring/query"
|
||||||
|
"github.com/thermokarst/bactdb/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Client communicates with bactdb's HTTP API.
|
||||||
|
type Client struct {
|
||||||
|
Users UsersService
|
||||||
|
|
||||||
|
// BaseURL for HTTP requests to bactdb's API.
|
||||||
|
BaseURL *url.URL
|
||||||
|
|
||||||
|
//UserAgent used for HTTP requests to bactdb's API.
|
||||||
|
UserAgent string
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
libraryVersion = "0.0.1"
|
||||||
|
userAgent = "bactdb-client/" + libraryVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewClient creates a new HTTP API client for bactdb. If httpClient == nil,
|
||||||
|
// then http.DefaultClient is used.
|
||||||
|
func NewClient(httpClient *http.Client) *Client {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Client{
|
||||||
|
BaseURL: &url.URL{Scheme: "http", Host: "bactdb.org", Path: "/api/"},
|
||||||
|
UserAgent: userAgent,
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
c.Users = &usersService{c}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOptions specifies general pagination options for fetching a list of results
|
||||||
|
type ListOptions struct {
|
||||||
|
PerPage int `url:",omitempty" json:",omitempty"`
|
||||||
|
Page int `url:",moitempty" json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ListOptions) PageOrDefault() int {
|
||||||
|
if o.Page <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return o.Page
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ListOptions) Offset() int {
|
||||||
|
return (o.PageOrDefault() - 1) * o.PerPageOrDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ListOptions) PerPageOrDefault() int {
|
||||||
|
if o.PerPage <= 0 {
|
||||||
|
return DefaultPerPage
|
||||||
|
}
|
||||||
|
return o.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPerPage is the default number of items to return in a paginated result set
|
||||||
|
const DefaultPerPage = 10
|
||||||
|
|
||||||
|
// apiRouter is used to generate URLs for bactdb's HTTP API.
|
||||||
|
var apiRouter = router.API()
|
||||||
|
|
||||||
|
// url generates the URL to the named bactdb API endpoint, using the
|
||||||
|
// specified route variables and query options.
|
||||||
|
func (c *Client) url(apiRouteName string, routeVars map[string]string, opt interface{}) (*url.URL, error) {
|
||||||
|
route := apiRouter.Get(apiRouteName)
|
||||||
|
if route == nil {
|
||||||
|
return nil, fmt.Errorf("no API route named %q", apiRouteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
routeVarsList := make([]string, 2*len(routeVars))
|
||||||
|
i := 0
|
||||||
|
for name, val := range routeVars {
|
||||||
|
routeVarsList[i*2] = name
|
||||||
|
routeVarsList[i*2+1] = val
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
url, err := route.URL(routeVarsList...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// make the route URL path relative to BaseURL by trimming the leading "/"
|
||||||
|
url.Path = strings.TrimPrefix(url.Path, "/")
|
||||||
|
|
||||||
|
if opt != nil {
|
||||||
|
err = addOptions(url, opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRequest creates an API request. A relative URL can be provided in urlStr,
|
||||||
|
// in which case it is resolved relative to the BaseURL of the Client. Relative
|
||||||
|
// URLs should always be specified without a preceding slash. If specified, the
|
||||||
|
// value pointed to by body is JSON encoded and included as the request body.
|
||||||
|
func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) {
|
||||||
|
rel, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u := c.BaseURL.ResolveReference(rel)
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if body != nil {
|
||||||
|
err := json.NewEncoder(buf).Encode(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, u.String(), buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("User-Agent", c.UserAgent)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do sends an API request and returns the API response. The API response is
|
||||||
|
// JSON-decoded and stored in the value pointed to by v, or returned as an error
|
||||||
|
// if an API error has occurred.
|
||||||
|
func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
err = CheckResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
// even though there was an error, we still return the response
|
||||||
|
// in case the caller wants to inspect it further
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if v != nil {
|
||||||
|
if bp, ok := v.(*[]byte); ok {
|
||||||
|
*bp, err = ioutil.ReadAll(resp.Body)
|
||||||
|
} else {
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading response from %s %s: %s", req.Method, req.URL.RequestURI(), err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addOptions adds the parameters in opt as URL query parameters to u. opt
|
||||||
|
// must be a struct whose fields may contain "url" tags.
|
||||||
|
func addOptions(u *url.URL, opt interface{}) error {
|
||||||
|
v := reflect.ValueOf(opt)
|
||||||
|
if v.Kind() == reflect.Ptr && v.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
qs, err := query.Values(opt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.RawQuery = qs.Encode()
|
||||||
|
return nil
|
||||||
|
}
|
95
models/client_test.go
Normal file
95
models/client_test.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// mux is the HTTP request multiplexer used with the test server.
|
||||||
|
mux *http.ServeMux
|
||||||
|
|
||||||
|
// client is the bactdb client being tested.
|
||||||
|
client *Client
|
||||||
|
|
||||||
|
// server is a test HTTP server used to provide mock API responses.
|
||||||
|
server *httptest.Server
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup sets up a test HTTP server along with a Client that is
|
||||||
|
// configured to talk to that test server. Tests should register handlers on
|
||||||
|
// mux which provide mock responses for the API method being tested.
|
||||||
|
func setup() {
|
||||||
|
// test server
|
||||||
|
mux = http.NewServeMux()
|
||||||
|
server = httptest.NewServer(mux)
|
||||||
|
|
||||||
|
// bactdb client configured to use test server
|
||||||
|
client = NewClient(nil)
|
||||||
|
url, _ := url.Parse(server.URL)
|
||||||
|
client.BaseURL = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// teardown closes the test HTTP server.
|
||||||
|
func teardown() {
|
||||||
|
server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlPath(t *testing.T, routeName string, routeVars map[string]string) string {
|
||||||
|
url, err := client.url(routeName, routeVars, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error constructing URL path for route %q with vars %+v: %s", routeName, routeVars, err)
|
||||||
|
}
|
||||||
|
return "/" + url.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
err := json.NewEncoder(w).Encode(v)
|
||||||
|
if err != nil {
|
||||||
|
panic("writeJSON: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMethod(t *testing.T, r *http.Request, want string) {
|
||||||
|
if want != r.Method {
|
||||||
|
t.Errorf("Request method = %v, want %v", r.Method, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type values map[string]string
|
||||||
|
|
||||||
|
func testFormValues(t *testing.T, r *http.Request, values values) {
|
||||||
|
want := url.Values{}
|
||||||
|
for k, v := range values {
|
||||||
|
want.Add(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseForm()
|
||||||
|
if !reflect.DeepEqual(want, r.Form) {
|
||||||
|
t.Errorf("Request parameters = %v, want %v", r.Form, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBody(t *testing.T, r *http.Request, want string) {
|
||||||
|
b, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unable to read body")
|
||||||
|
}
|
||||||
|
str := string(b)
|
||||||
|
if want != str {
|
||||||
|
t.Errorf("Body = %s, want: %s", str, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTime(t ...*time.Time) {
|
||||||
|
for _, v := range t {
|
||||||
|
*v = v.In(time.UTC)
|
||||||
|
}
|
||||||
|
}
|
56
models/errors.go
Normal file
56
models/errors.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An ErrorResponse reports errors caused by an API request.
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Response *http.Response `json:",omitempty"`
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ErrorResponse) Error() string {
|
||||||
|
return fmt.Sprintf("%v %v: %d %v",
|
||||||
|
r.Response.Request.Method, r.Response.Request.URL,
|
||||||
|
r.Response.StatusCode, r.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ErrorResponse) HTTPStatusCode() int {
|
||||||
|
return r.Response.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResponse checks the API response for errors, and returns them if
|
||||||
|
// present. A response is considered an error if it has a status code outside
|
||||||
|
// the 200 range. API error responses are expected to have either no response
|
||||||
|
// body, or a JSON response body that maps to ErrorResponse. Any other
|
||||||
|
// response body will be silently ignored.
|
||||||
|
func CheckResponse(r *http.Response) error {
|
||||||
|
if c := r.StatusCode; 200 <= c && c <= 299 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
errorResponse := &ErrorResponse{Response: r}
|
||||||
|
data, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err == nil && data != nil {
|
||||||
|
json.Unmarshal(data, errorResponse)
|
||||||
|
}
|
||||||
|
return errorResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsHTTPErrorCode(err error, statusCode int) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpError interface {
|
||||||
|
Error() string
|
||||||
|
HTTPStatusCode() int
|
||||||
|
}
|
||||||
|
if httpErr, ok := err.(httpError); ok {
|
||||||
|
return statusCode == httpErr.HTTPStatusCode()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
135
models/users.go
Normal file
135
models/users.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A User is a person that has administrative access to bactdb.
|
||||||
|
type User struct {
|
||||||
|
Id int64 `json:"id,omitempty"`
|
||||||
|
UserName string `sql:"size:100" json:"user_name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt time.Time `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersService interacts with the user-related endpoints in bactdb's API.
|
||||||
|
type UsersService interface {
|
||||||
|
// Get a user.
|
||||||
|
Get(id int64) (*User, error)
|
||||||
|
|
||||||
|
// List all users.
|
||||||
|
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 (
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type usersService struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *usersService) Get(id int64) (*User, error) {
|
||||||
|
// Pass in key value pairs as strings, so that the gorilla mux URL
|
||||||
|
// generation is happy.
|
||||||
|
strId := strconv.FormatInt(id, 10)
|
||||||
|
|
||||||
|
url, err := s.client.url(router.User, map[string]string{"Id": strId}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.client.NewRequest("GET", url.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *User
|
||||||
|
_, err = s.client.Do(req, &user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *usersService) List(opt *UserListOptions) ([]*User, error) {
|
||||||
|
url, err := s.client.url(router.Users, nil, opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.client.NewRequest("GET", url.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []*User
|
||||||
|
_, err = s.client.Do(req, &users)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockUsersService struct {
|
||||||
|
Get_ func(id int64) (*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) {
|
||||||
|
if s.Get_ == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if s.List_ == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.List_(opt)
|
||||||
|
}
|
107
models/users_test.go
Normal file
107
models/users_test.go
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/thermokarst/bactdb/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUsersService_Get(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
want := &User{Id: 1, UserName: "Test User"}
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
mux.HandleFunc(urlPath(t, router.User, map[string]string{"Id": "1"}), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
testMethod(t, r, "GET")
|
||||||
|
|
||||||
|
writeJSON(w, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
user, err := client.Users.Get(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Users.Get returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Fatal("!called")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(user, want) {
|
||||||
|
t.Errorf("Users.Get returned %+v, want %+v", user, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
want := []*User{{Id: 1, UserName: "Test User"}}
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
mux.HandleFunc(urlPath(t, router.Users, nil), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
testMethod(t, r, "GET")
|
||||||
|
testFormValues(t, r, values{})
|
||||||
|
|
||||||
|
writeJSON(w, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
users, err := client.Users.List(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Users.List returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Fatal("!called")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range want {
|
||||||
|
normalizeTime(&u.CreatedAt, &u.UpdatedAt, &u.DeletedAt)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(users, want) {
|
||||||
|
t.Errorf("Users.List return %+v, want %+v", users, want)
|
||||||
|
}
|
||||||
|
}
|
11
router/api.go
Normal file
11
router/api.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package router
|
||||||
|
|
||||||
|
import "github.com/gorilla/mux"
|
||||||
|
|
||||||
|
func API() *mux.Router {
|
||||||
|
m := mux.NewRouter()
|
||||||
|
m.Path("/users").Methods("GET").Name(Users)
|
||||||
|
m.Path("/users").Methods("POST").Name(CreateUser)
|
||||||
|
m.Path("/users/{Id:.+}").Methods("GET").Name(User)
|
||||||
|
return m
|
||||||
|
}
|
7
router/routes.go
Normal file
7
router/routes.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package router
|
||||||
|
|
||||||
|
const (
|
||||||
|
User = "user"
|
||||||
|
CreateUser = "user:create"
|
||||||
|
Users = "users"
|
||||||
|
)
|
4
test.sh
Executable file
4
test.sh
Executable file
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
PGTZ=UTC PGSSLMODE=disable go test -v ./...
|
||||||
|
|
Reference in a new issue