diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0314f51 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: go + +addons: + postgresql: 9.3 + +go: 1.3 + +before_script: + - psql -c 'create database bactdbtest;' -U postgres diff --git a/LICENSE b/LICENSE index f42b911..8b29826 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ 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 of this software and associated documentation files (the "Software"), to deal diff --git a/api/handler.go b/api/handler.go new file mode 100644 index 0000000..93dd042 --- /dev/null +++ b/api/handler.go @@ -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) + } +} diff --git a/api/helpers.go b/api/helpers.go new file mode 100644 index 0000000..85ab78c --- /dev/null +++ b/api/helpers.go @@ -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 +} diff --git a/api/helpers_test.go b/api/helpers_test.go new file mode 100644 index 0000000..966aedc --- /dev/null +++ b/api/helpers_test.go @@ -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) +} diff --git a/api/server_for_test.go b/api/server_for_test.go new file mode 100644 index 0000000..ef07063 --- /dev/null +++ b/api/server_for_test.go @@ -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 +} diff --git a/api/users.go b/api/users.go new file mode 100644 index 0000000..d783449 --- /dev/null +++ b/api/users.go @@ -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) +} diff --git a/api/users_test.go b/api/users_test.go new file mode 100644 index 0000000..232178c --- /dev/null +++ b/api/users_test.go @@ -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) + } +} diff --git a/cmd/bactdb/bactdb.go b/cmd/bactdb/bactdb.go new file mode 100644 index 0000000..2c563eb --- /dev/null +++ b/cmd/bactdb/bactdb.go @@ -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() +} diff --git a/datastore/datastore.go b/datastore/datastore.go new file mode 100644 index 0000000..b18c012 --- /dev/null +++ b/datastore/datastore.go @@ -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{}, + } +} diff --git a/datastore/datastore_test.go b/datastore/datastore_test.go new file mode 100644 index 0000000..1e952a3 --- /dev/null +++ b/datastore/datastore_test.go @@ -0,0 +1,9 @@ +package datastore + +import "time" + +func normalizeTime(t ...*time.Time) { + for _, v := range t { + *v = v.In(time.UTC) + } +} diff --git a/datastore/db.go b/datastore/db.go new file mode 100644 index 0000000..b2f4b96 --- /dev/null +++ b/datastore/db.go @@ -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 +} diff --git a/datastore/db_test.go b/datastore/db_test.go new file mode 100644 index 0000000..c8103e0 --- /dev/null +++ b/datastore/db_test.go @@ -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() +} diff --git a/datastore/users.go b/datastore/users.go new file mode 100644 index 0000000..ee233a4 --- /dev/null +++ b/datastore/users.go @@ -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 +} diff --git a/datastore/users_test.go b/datastore/users_test.go new file mode 100644 index 0000000..a3efdd6 --- /dev/null +++ b/datastore/users_test.go @@ -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) + } +} diff --git a/models/client.go b/models/client.go new file mode 100644 index 0000000..e65f082 --- /dev/null +++ b/models/client.go @@ -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 +} diff --git a/models/client_test.go b/models/client_test.go new file mode 100644 index 0000000..f4db754 --- /dev/null +++ b/models/client_test.go @@ -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) + } +} diff --git a/models/errors.go b/models/errors.go new file mode 100644 index 0000000..d11b172 --- /dev/null +++ b/models/errors.go @@ -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 +} diff --git a/models/users.go b/models/users.go new file mode 100644 index 0000000..76b8a04 --- /dev/null +++ b/models/users.go @@ -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) +} diff --git a/models/users_test.go b/models/users_test.go new file mode 100644 index 0000000..274aeb4 --- /dev/null +++ b/models/users_test.go @@ -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) + } +} diff --git a/router/api.go b/router/api.go new file mode 100644 index 0000000..df2effb --- /dev/null +++ b/router/api.go @@ -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 +} diff --git a/router/routes.go b/router/routes.go new file mode 100644 index 0000000..4e7dd46 --- /dev/null +++ b/router/routes.go @@ -0,0 +1,7 @@ +package router + +const ( + User = "user" + CreateUser = "user:create" + Users = "users" +) diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..9670bba --- /dev/null +++ b/test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +PGTZ=UTC PGSSLMODE=disable go test -v ./... +