Skip to content

Commit e29054a

Browse files
committed
Initial Commit
0 parents  commit e29054a

10 files changed

Lines changed: 700 additions & 0 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# An Easily Tested, SQL-backed JSON Web Service in Go.
2+
3+
This repo demonstrates how to write an easily-tested JSON web service in
4+
Go, with a PostgreSQL database. [It's a companion to a blog post I wrote](http://nerdyc.com/blog/2016/06/16/testing-web-apps-end-to-end-in-go/).
5+
6+
Tests in this project run against a local PostgreSQL server, and make
7+
real HTTP calls to ensure that it works from end-to-end.
8+
9+
## Overview
10+
11+
Here's the gist of how tests work:
12+
13+
* The `test` sub-package contains shared test setup and helpers.
14+
15+
* The `test.Env` type manages setting up and tearing down global test state, like the connection to PostgreSQL.
16+
17+
* PostgreSQL is run locally (I use [Postgres.app](http://postgresapp.com)), and located via the `DATABASE_URL` environment variable.
18+
19+
* The [`migrate`](https://github.com/mattes/migrate) package is used to create a clean database before each test.
20+
21+
* The standard [`net/http/httptest`](https://golang.org/pkg/net/http/httptest/) package provides a test HTTP server that runs the API code.
22+
23+
* Test requests are made using standard HTTP.
24+
25+
Dive in and look at the tests, or [read the blog post for more detail](http://nerdyc.com/blog/2016/06/16/testing-web-apps-end-to-end-in-go/).
26+
27+
## Contact Me!
28+
29+
I'd love to know if this helps you! I'm [@nerdyc](https://twitter.com/nerdyc) on Twitter.

client.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package service
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"io/ioutil"
9+
"net/http"
10+
"net/url"
11+
)
12+
13+
// Client defines the interface exposed by our API.
14+
type Client interface {
15+
AddContact(contact AddContactRequest) (*Contact, error)
16+
GetContactByEmail(email string) (*Contact, error)
17+
}
18+
19+
// ErrorResponse is returned by our service when an error occurs.
20+
type ErrorResponse struct {
21+
StatusCode int `json:"status_code"`
22+
Message string `json:"message"`
23+
}
24+
25+
func (e ErrorResponse) Error() string {
26+
return fmt.Sprintf("%v: %v", e.StatusCode, e.Message)
27+
}
28+
29+
// NewClient creates a Client that accesses a service at the given base URL.
30+
func NewClient(baseURL string) Client {
31+
httpClient := http.DefaultClient
32+
return &DefaultClient{
33+
http: httpClient,
34+
BaseURL: baseURL,
35+
}
36+
}
37+
38+
// ===== DefaultClient =================================================================================================
39+
40+
// DefaultClient provides an implementation of the Client interface.
41+
type DefaultClient struct {
42+
http *http.Client
43+
BaseURL string
44+
}
45+
46+
// performRequestMethod constructs a request and uses `performRequest` to execute it.
47+
func (c *DefaultClient) performRequestMethod(method string, path string, headers map[string]string, data interface{}, response interface{}) error {
48+
49+
req, err := c.newRequest(method, path, headers, data)
50+
if err != nil {
51+
return err
52+
}
53+
54+
return c.performRequest(req, response)
55+
}
56+
57+
// performRequest executes the given request, and uses `response` to parse the JSON response.
58+
func (c *DefaultClient) performRequest(req *http.Request, response interface{}) error {
59+
// perform the request
60+
httpResponse, err := c.http.Do(req)
61+
if err != nil {
62+
return err
63+
}
64+
65+
defer httpResponse.Body.Close()
66+
67+
// read the response
68+
var responseBody []byte
69+
if responseBody, err = ioutil.ReadAll(httpResponse.Body); err != nil {
70+
return err
71+
}
72+
73+
if httpResponse.StatusCode >= 400 {
74+
contentTypeHeader := httpResponse.Header["Content-Type"]
75+
if len(contentTypeHeader) != 0 && contentTypeHeader[0] == "application/json" {
76+
var errResponse ErrorResponse
77+
err := json.Unmarshal(responseBody, &errResponse)
78+
if err == nil {
79+
return errResponse
80+
}
81+
}
82+
83+
return &ErrorResponse{
84+
StatusCode: httpResponse.StatusCode,
85+
Message: httpResponse.Status,
86+
}
87+
}
88+
89+
// map the response to an object value
90+
if err := json.Unmarshal(responseBody, &response); err != nil {
91+
return err
92+
}
93+
94+
return nil
95+
}
96+
97+
// newRequest builds a new request using the given parameters.
98+
func (c *DefaultClient) newRequest(method string, path string, headers map[string]string, data interface{}) (*http.Request, error) {
99+
100+
// Construct request body
101+
var body io.Reader
102+
if data != nil {
103+
requestJSON, err := json.Marshal(data)
104+
if err != nil {
105+
return nil, err
106+
}
107+
108+
body = bytes.NewReader(requestJSON)
109+
}
110+
111+
// construct the request
112+
req, err := http.NewRequest(method, c.BaseURL+path, body)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
if body != nil {
118+
req.Header.Set("Content-Type", "application/json")
119+
}
120+
121+
for k, v := range headers {
122+
req.Header.Set(k, v)
123+
}
124+
125+
return req, nil
126+
}
127+
128+
129+
130+
// ----- Add Contact ---------------------------------------------------------------------------------------------------
131+
132+
type AddContactRequest struct {
133+
Email string `json:"email"`
134+
Name string `json:"name"`
135+
}
136+
137+
type ContactResponse struct {
138+
Contact *Contact `json:"contact"`
139+
}
140+
141+
func (c *DefaultClient) AddContact(contact AddContactRequest) (*Contact, error) {
142+
var response ContactResponse
143+
err := c.performRequestMethod(http.MethodPost, "/contacts", nil, contact, &response)
144+
if err != nil {
145+
return nil, err
146+
}
147+
148+
return response.Contact, nil
149+
}
150+
151+
func (c *DefaultClient) GetContactByEmail(email string) (*Contact, error) {
152+
var response ContactResponse
153+
var path = fmt.Sprintf("/contacts/%v", url.QueryEscape(email))
154+
err := c.performRequestMethod(http.MethodGet, path, nil, nil, &response)
155+
if err != nil {
156+
return nil, err
157+
}
158+
159+
return response.Contact, nil
160+
}

contacts.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package service
2+
3+
import "database/sql"
4+
5+
// Contact describes a contact in our database.
6+
type Contact struct {
7+
Id int `json:"id"`
8+
Email string `json:"email"`
9+
Name string `json:"name"`
10+
}
11+
12+
// ===== ADD CONTACT ===================================================================================================
13+
14+
// AddContact inserts a new contact into the database.
15+
func (db *Database) AddContact(c Contact) (int, error) {
16+
var contactId int
17+
err := db.Write(func(tx *Transaction) {
18+
contactId = tx.AddContact(c)
19+
})
20+
21+
return contactId, err
22+
}
23+
24+
// AddContact inserts a new contact within the transaction.
25+
func (tx *Transaction) AddContact(c Contact) int {
26+
row := tx.QueryRow(
27+
"INSERT INTO contacts (email, name) VALUES ($1, $2) RETURNING id",
28+
c.Email,
29+
c.Name,
30+
)
31+
32+
var id int
33+
if err := row.Scan(&id); err != nil {
34+
panic(err)
35+
}
36+
37+
return id
38+
}
39+
40+
// ===== GET CONTACT ===================================================================================================
41+
42+
// GetContactByEmail reads a Contact from the Database.
43+
func (db *Database) GetContactByEmail(email string) (*Contact, error) {
44+
var contact *Contact
45+
err := db.Read(func(tx *Transaction) {
46+
contact = tx.GetContactByEmail(email)
47+
})
48+
49+
return contact, err
50+
}
51+
52+
// GetContactByEmail finds a contact given an email address. `nil` is returned if the Contact doesn't exist in the DB.
53+
func (tx *Transaction) GetContactByEmail(email string) *Contact {
54+
row := tx.QueryRow(
55+
"SELECT id, email, name FROM contacts WHERE email = $1",
56+
email,
57+
)
58+
59+
var contact Contact
60+
err := row.Scan(&contact.Id, &contact.Email, &contact.Name)
61+
if err == nil {
62+
return &contact
63+
} else if err == sql.ErrNoRows {
64+
return nil
65+
} else {
66+
panic(err)
67+
}
68+
}

database.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package service
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
"log"
7+
)
8+
9+
// Database wraps our SQL database. Defining our own type allows us to define helper functions on the Database.
10+
type Database struct {
11+
DB *sql.DB
12+
}
13+
14+
func (db *Database) Close() {
15+
db.DB.Close()
16+
}
17+
18+
// ===== TRANSACTIONS ==================================================================================================
19+
20+
// Transaction wraps a SQL transaction. Defining our own type allows functions to be defined on the Transaction.
21+
type Transaction struct {
22+
*sql.Tx
23+
db *Database
24+
}
25+
26+
type TransactionFunc func(*Transaction)
27+
28+
func (db *Database) begin() (*Transaction, error) {
29+
tx, err := db.DB.Begin()
30+
if err != nil {
31+
return nil, err
32+
}
33+
34+
return &Transaction{tx, db}, nil
35+
}
36+
37+
// Read begins a read-only transaction and passes it to the given function. The transaction will be rolled back after
38+
// the function returns. Any panics will be handled, and returned as an error.
39+
func (db *Database) Read(reader TransactionFunc) (err error) {
40+
tx, err := db.begin()
41+
if err != nil {
42+
return err
43+
}
44+
45+
// A read should always rollback the transaction
46+
defer func() {
47+
if rollbackErr := tx.Rollback(); rollbackErr != nil {
48+
// Ignore errors rolling back
49+
log.Println(rollbackErr.Error())
50+
}
51+
}()
52+
53+
// recover any panics during the transaction, and return it as an error to the caller
54+
defer func() {
55+
if r := recover(); r != nil {
56+
var ok bool
57+
err, ok = r.(error)
58+
if !ok {
59+
err = fmt.Errorf("Database.Read: %v", r)
60+
}
61+
}
62+
}()
63+
64+
// Mark the transaction as read only
65+
_, err = tx.Exec("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
66+
if err != nil {
67+
panic(fmt.Errorf("Unable to mark transaction read-only"))
68+
}
69+
70+
reader(tx) // Code in this function can panic
71+
return err
72+
}
73+
74+
// Write begins a transaction and passes it to the given function. The transaction will be committed when the function
75+
// returns. If the function panics, the transaction is rolled back, and the error provided to panic is returned.
76+
func (db *Database) Write(writer TransactionFunc) (err error) {
77+
tx, err := db.begin()
78+
if err != nil {
79+
return err
80+
}
81+
82+
didPanic := false
83+
84+
// write operations commit or rollback the transaction
85+
defer func() {
86+
if didPanic {
87+
if rollbackErr := tx.Rollback(); rollbackErr != nil {
88+
// Ignore errors rolling back
89+
log.Println(rollbackErr.Error())
90+
}
91+
} else {
92+
if commitErr := tx.Commit(); commitErr != nil {
93+
err = commitErr
94+
}
95+
}
96+
}()
97+
98+
// recover any panics during the transaction, and return it as an error to the caller
99+
defer func() {
100+
if r := recover(); r != nil {
101+
didPanic = true
102+
103+
var ok bool
104+
err, ok = r.(error)
105+
if !ok {
106+
err = fmt.Errorf("Database.Write: %v", r)
107+
}
108+
}
109+
}()
110+
111+
writer(tx) // If the function panics, the transaction will be rolled back
112+
113+
return err
114+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE contacts;

db/migrations/001_contacts.up.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE contacts (
2+
id SERIAL PRIMARY KEY,
3+
email varchar(255) UNIQUE NOT NULL,
4+
name varchar(255) NOT NULL
5+
);

0 commit comments

Comments
 (0)