add a database interaction example
Этот коммит содержится в:
родитель
d25440860b
коммит
e99804d43f
8 изменённых файлов: 458 добавлений и 3 удалений
7
Makefile
7
Makefile
|
@ -1,14 +1,15 @@
|
|||
.PHONY: test
|
||||
.PHONY: test deps
|
||||
|
||||
# runs all necessary tests
|
||||
test:
|
||||
@echo "running all tests"
|
||||
@sh -c 'if [ ! -z "$(go fmt ./...)" ]; then exit 1; else echo "go fmt OK"; fi'
|
||||
@sh -c 'if [ ! -z "$(golint ./...)" ]; then exit 1; else echo "golint OK"; fi'
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go run cmd/godog/main.go -f progress
|
||||
|
||||
# updates dependencies
|
||||
deps:
|
||||
@echo "updating all dependencies"
|
||||
go get -u github.com/cucumber/gherkin-go
|
||||
go get -u github.com/shiena/ansicolor
|
||||
|
||||
|
|
8
examples/db/Makefile
Обычный файл
8
examples/db/Makefile
Обычный файл
|
@ -0,0 +1,8 @@
|
|||
.PHONY: test
|
||||
|
||||
test:
|
||||
mysql -u root -e 'DROP DATABASE IF EXISTS `godog_test`'
|
||||
mysql -u root -e 'CREATE DATABASE IF NOT EXISTS `godog_test`'
|
||||
mysql -u root godog_test < db.sql
|
||||
godog users.feature
|
||||
|
17
examples/db/README.md
Обычный файл
17
examples/db/README.md
Обычный файл
|
@ -0,0 +1,17 @@
|
|||
# An example of API with DB
|
||||
|
||||
The following example demonstrates steps how we describe and test our API with DB using **godog**.
|
||||
To start with, see [API example](https://github.com/DATA-DOG/godog/tree/master/examples/api) before.
|
||||
We have extended it to be used with database.
|
||||
|
||||
The interesting point is, that we have **txdb.go** which has an implementation of custom sql.driver
|
||||
to allow execute every and each scenario within a **transaction**. After it completes, transaction
|
||||
is rolled back so the state could be clean for the next scenario.
|
||||
|
||||
To run **users.feature** you need MySQL installed on your system with an anonymous root password.
|
||||
Then run:
|
||||
|
||||
make test
|
||||
|
||||
The json comparisom function should be improved and we should also have placeholders for primary
|
||||
keys when comparing a json result.
|
95
examples/db/api.go
Обычный файл
95
examples/db/api.go
Обычный файл
|
@ -0,0 +1,95 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"-"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"-"`
|
||||
}
|
||||
|
||||
func (s *server) users(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
fail(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
users := make([]*User, 0)
|
||||
rows, err := s.db.Query("SELECT id, email, username FROM users")
|
||||
defer rows.Close()
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
case nil:
|
||||
for rows.Next() {
|
||||
user := &User{}
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.Username); err != nil {
|
||||
fail(w, fmt.Sprintf("failed to scan an user: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
default:
|
||||
fail(w, fmt.Sprintf("failed to fetch users: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Users []*User `json:"users"`
|
||||
}{Users: users}
|
||||
|
||||
ok(w, data)
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("mysql", "root@/godog")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s := &server{db: db}
|
||||
http.HandleFunc("/users", s.users)
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
|
||||
// fail writes a json response with error msg and status header
|
||||
func fail(w http.ResponseWriter, msg string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
data := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: msg}
|
||||
|
||||
resp, _ := json.Marshal(data)
|
||||
w.WriteHeader(status)
|
||||
|
||||
fmt.Fprintf(w, string(resp))
|
||||
}
|
||||
|
||||
// ok writes data to response with 200 status
|
||||
func ok(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if s, ok := data.(string); ok {
|
||||
fmt.Fprintf(w, s)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fail(w, "oops something evil has happened", 500)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, string(resp))
|
||||
}
|
129
examples/db/api_test.go
Обычный файл
129
examples/db/api_test.go
Обычный файл
|
@ -0,0 +1,129 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/DATA-DOG/godog"
|
||||
"github.com/cucumber/gherkin-go"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// we register an sql driver txdb
|
||||
Register("mysql", "root@/godog_test")
|
||||
}
|
||||
|
||||
type apiFeature struct {
|
||||
server
|
||||
resp *httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
func (a *apiFeature) resetResponse(interface{}) {
|
||||
a.resp = httptest.NewRecorder()
|
||||
if a.db != nil {
|
||||
a.db.Close()
|
||||
}
|
||||
db, err := sql.Open("txdb", "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
a.db = db
|
||||
}
|
||||
|
||||
func (a *apiFeature) iSendrequestTo(method, endpoint string) (err error) {
|
||||
req, err := http.NewRequest(method, endpoint, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// handle panic
|
||||
defer func() {
|
||||
switch t := recover().(type) {
|
||||
case string:
|
||||
err = fmt.Errorf(t)
|
||||
case error:
|
||||
err = t
|
||||
}
|
||||
}()
|
||||
|
||||
switch endpoint {
|
||||
case "/users":
|
||||
a.users(a.resp, req)
|
||||
default:
|
||||
err = fmt.Errorf("unknown endpoint: %s", endpoint)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *apiFeature) theResponseCodeShouldBe(code int) error {
|
||||
if code != a.resp.Code {
|
||||
if a.resp.Code >= 400 {
|
||||
return fmt.Errorf("expected response code to be: %d, but actual is: %d, response message: %s", code, a.resp.Code, string(a.resp.Body.Bytes()))
|
||||
}
|
||||
return fmt.Errorf("expected response code to be: %d, but actual is: %d", code, a.resp.Code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *apiFeature) theResponseShouldMatchJSON(body *gherkin.DocString) (err error) {
|
||||
var expected, actual []byte
|
||||
var data interface{}
|
||||
if err = json.Unmarshal([]byte(body.Content), &data); err != nil {
|
||||
return
|
||||
}
|
||||
if expected, err = json.Marshal(data); err != nil {
|
||||
return
|
||||
}
|
||||
actual = a.resp.Body.Bytes()
|
||||
if string(actual) != string(expected) {
|
||||
err = fmt.Errorf("expected json %s, does not match actual: %s", string(expected), string(actual))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *apiFeature) thereAreUsers(users *gherkin.DataTable) error {
|
||||
var fields []string
|
||||
var marks []string
|
||||
head := users.Rows[0].Cells
|
||||
for _, cell := range head {
|
||||
fields = append(fields, cell.Value)
|
||||
marks = append(marks, "?")
|
||||
}
|
||||
|
||||
stmt, err := a.db.Prepare("INSERT INTO users (" + strings.Join(fields, ", ") + ") VALUES(" + strings.Join(marks, ", ") + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 1; i < len(users.Rows); i++ {
|
||||
var vals []interface{}
|
||||
for n, cell := range users.Rows[i].Cells {
|
||||
switch head[n].Value {
|
||||
case "username":
|
||||
vals = append(vals, cell.Value)
|
||||
case "email":
|
||||
vals = append(vals, cell.Value)
|
||||
default:
|
||||
return fmt.Errorf("unexpected column name: %s", head[n].Value)
|
||||
}
|
||||
}
|
||||
if _, err = stmt.Exec(vals...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func featureContext(s godog.Suite) {
|
||||
api := &apiFeature{}
|
||||
|
||||
s.BeforeScenario(api.resetResponse)
|
||||
|
||||
s.Step(`^I send "(GET|POST|PUT|DELETE)" request to "([^"]*)"$`, api.iSendrequestTo)
|
||||
s.Step(`^the response code should be (\d+)$`, api.theResponseCodeShouldBe)
|
||||
s.Step(`^the response should match json:$`, api.theResponseShouldMatchJSON)
|
||||
s.Step(`^there are users:$`, api.thereAreUsers)
|
||||
}
|
7
examples/db/db.sql
Обычный файл
7
examples/db/db.sql
Обычный файл
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE users (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL,
|
||||
`username` VARCHAR(32) NOT NULL,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `uniq_email` (`email`)
|
||||
) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
|
146
examples/db/txdb.go
Обычный файл
146
examples/db/txdb.go
Обычный файл
|
@ -0,0 +1,146 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Register a txdb sql driver which can be used to open
|
||||
// a single transaction based database connection pool
|
||||
func Register(drv, dsn string) {
|
||||
sql.Register("txdb", &txDriver{dsn: dsn, drv: drv})
|
||||
}
|
||||
|
||||
// txDriver is an sql driver which runs on single transaction
|
||||
// when the Close is called, transaction is rolled back
|
||||
type txDriver struct {
|
||||
sync.Mutex
|
||||
tx *sql.Tx
|
||||
|
||||
drv string
|
||||
dsn string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (d *txDriver) Open(dsn string) (driver.Conn, error) {
|
||||
// first open a real database connection
|
||||
var err error
|
||||
if d.db == nil {
|
||||
db, err := sql.Open(d.drv, d.dsn)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.db = db
|
||||
}
|
||||
if d.tx == nil {
|
||||
d.tx, err = d.db.Begin()
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (d *txDriver) Close() error {
|
||||
err := d.tx.Rollback()
|
||||
d.tx = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *txDriver) Begin() (driver.Tx, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *txDriver) Commit() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *txDriver) Rollback() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *txDriver) Prepare(query string) (driver.Stmt, error) {
|
||||
return &stmt{drv: d, query: query}, nil
|
||||
}
|
||||
|
||||
type stmt struct {
|
||||
query string
|
||||
drv *txDriver
|
||||
}
|
||||
|
||||
func (s *stmt) Exec(args []driver.Value) (driver.Result, error) {
|
||||
s.drv.Lock()
|
||||
defer s.drv.Unlock()
|
||||
|
||||
st, err := s.drv.tx.Prepare(s.query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
var iargs []interface{}
|
||||
for _, arg := range args {
|
||||
iargs = append(iargs, arg)
|
||||
}
|
||||
return st.Exec(iargs...)
|
||||
}
|
||||
|
||||
func (s *stmt) NumInput() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *stmt) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stmt) Query(args []driver.Value) (driver.Rows, error) {
|
||||
s.drv.Lock()
|
||||
defer s.drv.Unlock()
|
||||
|
||||
st, err := s.drv.tx.Prepare(s.query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// do not close the statement here, Rows need it
|
||||
var iargs []interface{}
|
||||
for _, arg := range args {
|
||||
iargs = append(iargs, arg)
|
||||
}
|
||||
rs, err := st.Query(iargs...)
|
||||
return &rows{rs: rs}, err
|
||||
}
|
||||
|
||||
type rows struct {
|
||||
err error
|
||||
rs *sql.Rows
|
||||
}
|
||||
|
||||
func (r *rows) Columns() (cols []string) {
|
||||
cols, r.err = r.rs.Columns()
|
||||
return
|
||||
}
|
||||
|
||||
func (r *rows) Next(dest []driver.Value) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if r.rs.Err() != nil {
|
||||
return r.rs.Err()
|
||||
}
|
||||
if !r.rs.Next() {
|
||||
return io.EOF
|
||||
}
|
||||
values := make([]interface{}, len(dest))
|
||||
for i := range values {
|
||||
values[i] = new(interface{})
|
||||
}
|
||||
if err := r.rs.Scan(values...); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, val := range values {
|
||||
dest[i] = *(val.(*interface{}))
|
||||
}
|
||||
return r.rs.Err()
|
||||
}
|
||||
|
||||
func (r *rows) Close() error {
|
||||
return r.rs.Close()
|
||||
}
|
52
examples/db/users.feature
Обычный файл
52
examples/db/users.feature
Обычный файл
|
@ -0,0 +1,52 @@
|
|||
Feature: users
|
||||
In order to use users api
|
||||
As an API user
|
||||
I need to be able to manage users
|
||||
|
||||
Scenario: should get empty users
|
||||
When I send "GET" request to "/users"
|
||||
Then the response code should be 200
|
||||
And the response should match json:
|
||||
"""
|
||||
{
|
||||
"users": []
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: should get users
|
||||
Given there are users:
|
||||
| username | email |
|
||||
| john | john.doe@mail.com |
|
||||
| jane | jane.doe@mail.com |
|
||||
When I send "GET" request to "/users"
|
||||
Then the response code should be 200
|
||||
And the response should match json:
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "john"
|
||||
},
|
||||
{
|
||||
"username": "jane"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: should get users when there is only one
|
||||
Given there are users:
|
||||
| username | email |
|
||||
| gopher | gopher@mail.com |
|
||||
When I send "GET" request to "/users"
|
||||
Then the response code should be 200
|
||||
And the response should match json:
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "gopher"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
Загрузка…
Создание таблицы
Сослаться в новой задаче