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)
|
||||
|
||||
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
|
||||
|
|
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