diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..495c35f --- /dev/null +++ b/api/api.go @@ -0,0 +1,7 @@ +package api + +import "github.com/gorilla/mux" + +func Handler() *mux.Router { + return mux.NewRouter() +} diff --git a/cmd/bactdb/bactdb.go b/cmd/bactdb/bactdb.go new file mode 100644 index 0000000..df1a33a --- /dev/null +++ b/cmd/bactdb/bactdb.go @@ -0,0 +1,94 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + + "github.com/thermokarst/bactdb/api" +) + +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}, +} + +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() + } + + m := http.NewServeMux() + m.Handle("/api", api.Handler()) + + log.Print("Listening on ", *httpAddr) + err := http.ListenAndServe(*httpAddr, m) + if err != nil { + log.Fatal("ListenAndServe:", err) + } +} diff --git a/models/client.go b/models/client.go new file mode 100644 index 0000000..d217a15 --- /dev/null +++ b/models/client.go @@ -0,0 +1,162 @@ +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 +} + +// 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..b9e151d --- /dev/null +++ b/models/users.go @@ -0,0 +1,62 @@ +package models + +import ( + "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"` + 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) +} + +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 +} + +type MockUsersService struct { + Get_ func(id int64) (*User, error) +} + +func (s *MockUsersService) Get(id int64) (*User, error) { + if s.Get_ == nil { + return nil, nil + } + return s.Get_(id) +} diff --git a/models/users_test.go b/models/users_test.go new file mode 100644 index 0000000..6bba3e1 --- /dev/null +++ b/models/users_test.go @@ -0,0 +1,39 @@ +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) + } +} diff --git a/router/api.go b/router/api.go new file mode 100644 index 0000000..4ab6461 --- /dev/null +++ b/router/api.go @@ -0,0 +1,9 @@ +package router + +import "github.com/gorilla/mux" + +func API() *mux.Router { + m := mux.NewRouter() + 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..43e73cc --- /dev/null +++ b/router/routes.go @@ -0,0 +1,5 @@ +package router + +const ( + User = "user" +) diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..7a8971c --- /dev/null +++ b/test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +go test ./... +