Rocking github.com/sourcegraph/thesrc template
- Working in client, router, and basic models (user)
This commit is contained in:
parent
a1d954d5fd
commit
da7be2e150
10 changed files with 533 additions and 0 deletions
7
api/api.go
Normal file
7
api/api.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package api
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
func Handler() *mux.Router {
|
||||
return mux.NewRouter()
|
||||
}
|
94
cmd/bactdb/bactdb.go
Normal file
94
cmd/bactdb/bactdb.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
162
models/client.go
Normal file
162
models/client.go
Normal file
|
@ -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
|
||||
}
|
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
|
||||
}
|
62
models/users.go
Normal file
62
models/users.go
Normal file
|
@ -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)
|
||||
}
|
39
models/users_test.go
Normal file
39
models/users_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
9
router/api.go
Normal file
9
router/api.go
Normal file
|
@ -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
|
||||
}
|
5
router/routes.go
Normal file
5
router/routes.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package router
|
||||
|
||||
const (
|
||||
User = "user"
|
||||
)
|
4
test.sh
Executable file
4
test.sh
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
go test ./...
|
||||
|
Reference in a new issue