Merge pull request #1 from thermokarst/initial-architecture

Initial architecture.
This commit is contained in:
Matthew Dillon 2014-10-03 16:03:34 -08:00
commit 10a3a604be
23 changed files with 1330 additions and 1 deletions

9
.travis.yml Normal file
View file

@ -0,0 +1,9 @@
language: go
addons:
postgresql: 9.3
go: 1.3
before_script:
- psql -c 'create database bactdbtest;' -U postgres

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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{},
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
package router
const (
User = "user"
CreateUser = "user:create"
Users = "users"
)

4
test.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
PGTZ=UTC PGSSLMODE=disable go test -v ./...