├── .travis.yml ├── README.md ├── Users.go ├── app.go ├── connect.sh ├── docker-compose.yml ├── main.go ├── main_test.go ├── model.go ├── sql └── users.sql └── test-connect.sh /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.8 5 | - 1.11 6 | - tip 7 | 8 | services: 9 | - mysql 10 | 11 | before_install: 12 | - mysql -e 'CREATE DATABASE IF NOT EXISTS rest_api_example;' 13 | 14 | install: 15 | - go get golang.org/x/tools/cmd/cover 16 | - go get github.com/mattn/goveralls 17 | - go get github.com/gorilla/mux 18 | - go get github.com/go-sql-driver/mysql 19 | 20 | script: 21 | - go test -covermode=count -coverprofile=coverage.out 22 | - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | If the JSON payload misses a value on POST (INSERT) or PUT (UPDATE), that value if it's optional, becomes null. 2 | 3 | Test should use transactions, like so: https://www.reddit.com/r/golang/comments/b6rs6w/testing_database_models_interfaces_without/ejmoaq7/ 4 | -------------------------------------------------------------------------------- /Users.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type user struct { 4 | ID int `db:"id" json:"id"` 5 | Name string `db:"name" json:"name"` 6 | Age *int `db:"age" json:"age,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | // app.go 2 | 3 | package main 4 | 5 | import ( 6 | "database/sql" 7 | "encoding/json" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | 12 | _ "github.com/go-sql-driver/mysql" 13 | "github.com/gorilla/mux" 14 | "github.com/jmoiron/sqlx" 15 | ) 16 | 17 | type App struct { 18 | Router *mux.Router 19 | DB *sqlx.DB 20 | } 21 | 22 | func (a *App) Initialize() { 23 | connectionString := "root:secret@tcp(localhost:3306)/rest_api_example?multiStatements=true&sql_mode=TRADITIONAL&timeout=5s" 24 | var err error 25 | a.DB, err = sqlx.Open("mysql", connectionString) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | a.Router = mux.NewRouter() 30 | a.initializeRoutes() 31 | } 32 | 33 | func (a *App) Run(addr string) { 34 | log.Fatal(http.ListenAndServe(addr, a.Router)) 35 | } 36 | 37 | func (a *App) initializeRoutes() { 38 | a.Router.HandleFunc("/users", a.getUsers).Methods("GET") 39 | a.Router.HandleFunc("/user", a.createUser).Methods("POST") 40 | a.Router.HandleFunc("/user/{id:[0-9]+}", a.getUser).Methods("GET") 41 | a.Router.HandleFunc("/user/{id:[0-9]+}", a.updateUser).Methods("PUT") 42 | a.Router.HandleFunc("/user/{id:[0-9]+}", a.deleteUser).Methods("DELETE") 43 | } 44 | 45 | func (a *App) getUsers(w http.ResponseWriter, r *http.Request) { 46 | count, _ := strconv.Atoi(r.FormValue("count")) 47 | startid, _ := strconv.Atoi(r.FormValue("id")) 48 | 49 | if count < 1 { 50 | count = 100 51 | } 52 | if startid < 0 { 53 | startid = 0 54 | } 55 | 56 | users, err := getUsers(a.DB, startid, count) 57 | if err != nil { 58 | respondWithError(w, http.StatusInternalServerError, err.Error()) 59 | return 60 | } 61 | log.Printf("%#v len: %d", users, len(users)) 62 | 63 | respondWithJSON(w, http.StatusOK, users) 64 | } 65 | 66 | func (a *App) createUser(w http.ResponseWriter, r *http.Request) { 67 | var u user 68 | decoder := json.NewDecoder(r.Body) 69 | if err := decoder.Decode(&u); err != nil { 70 | respondWithError(w, http.StatusBadRequest, "Invalid request payload") 71 | return 72 | } 73 | defer r.Body.Close() 74 | 75 | if err := u.createUser(a.DB); err != nil { 76 | respondWithError(w, http.StatusInternalServerError, err.Error()) 77 | return 78 | } 79 | 80 | respondWithJSON(w, http.StatusCreated, u) 81 | } 82 | 83 | func (a *App) getUser(w http.ResponseWriter, r *http.Request) { 84 | vars := mux.Vars(r) 85 | id, err := strconv.Atoi(vars["id"]) 86 | if err != nil { 87 | respondWithError(w, http.StatusBadRequest, "Invalid user ID") 88 | return 89 | } 90 | 91 | u := user{ID: id} 92 | if err := u.getUser(a.DB); err != nil { 93 | switch err { 94 | case sql.ErrNoRows: 95 | respondWithError(w, http.StatusNotFound, "User not found") 96 | default: 97 | respondWithError(w, http.StatusInternalServerError, err.Error()) 98 | } 99 | return 100 | } 101 | 102 | respondWithJSON(w, http.StatusOK, u) 103 | } 104 | 105 | func (a *App) updateUser(w http.ResponseWriter, r *http.Request) { 106 | vars := mux.Vars(r) 107 | id, err := strconv.Atoi(vars["id"]) 108 | if err != nil { 109 | respondWithError(w, http.StatusBadRequest, "Invalid user ID") 110 | return 111 | } 112 | 113 | var u user 114 | decoder := json.NewDecoder(r.Body) 115 | if err := decoder.Decode(&u); err != nil { 116 | respondWithError(w, http.StatusBadRequest, "Invalid resquest payload") 117 | return 118 | } 119 | defer r.Body.Close() 120 | u.ID = id 121 | 122 | if err := u.updateUser(a.DB); err != nil { 123 | respondWithError(w, http.StatusInternalServerError, err.Error()) 124 | return 125 | } 126 | 127 | respondWithJSON(w, http.StatusOK, u) 128 | } 129 | 130 | func (a *App) deleteUser(w http.ResponseWriter, r *http.Request) { 131 | vars := mux.Vars(r) 132 | id, err := strconv.Atoi(vars["id"]) 133 | if err != nil { 134 | respondWithError(w, http.StatusBadRequest, "Invalid User ID") 135 | return 136 | } 137 | 138 | u := user{ID: id} 139 | if err := u.deleteUser(a.DB); err != nil { 140 | respondWithError(w, http.StatusInternalServerError, err.Error()) 141 | return 142 | } 143 | 144 | respondWithJSON(w, http.StatusOK, map[string]string{"result": "success"}) 145 | } 146 | 147 | func respondWithError(w http.ResponseWriter, code int, message string) { 148 | respondWithJSON(w, code, map[string]string{"error": message}) 149 | } 150 | 151 | func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { 152 | response, _ := json.Marshal(payload) 153 | 154 | w.Header().Set("Content-Type", "application/json") 155 | w.WriteHeader(code) 156 | w.Write(response) 157 | } 158 | -------------------------------------------------------------------------------- /connect.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # assuming mariadb is set to localhost in your /etc/hosts 3 | mysql -h mariadb -P 3306 -u root --password=secret rest_api_example 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | mariadb: 5 | image: mariadb 6 | environment: 7 | MYSQL_ROOT_PASSWORD: secret 8 | ports: 9 | - 3306:3306 10 | volumes: 11 | - ./sql/:/docker-entrypoint-initdb.d/ 12 | networks: 13 | - network 14 | 15 | adminer: 16 | image: adminer 17 | depends_on: 18 | - mariadb 19 | environment: 20 | ADMINER_DEFAULT_SERVER: mariadb 21 | ADMINER_DEFAULT_DB_DRIVER: mysql 22 | ADMINER_DEFAULT_DB_HOST: mariadb 23 | ports: 24 | - 8082:8080 25 | networks: 26 | - network 27 | 28 | networks: 29 | network: 30 | driver: bridge 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // main.go 2 | 3 | package main 4 | 5 | import "os" 6 | 7 | func main() { 8 | a := App{} 9 | a.Initialize() 10 | 11 | a.Run(":" + os.Getenv("PORT")) 12 | } 13 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // main_test.go 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "strconv" 14 | "testing" 15 | ) 16 | 17 | var a App 18 | 19 | func TestMain(m *testing.M) { 20 | a = App{} 21 | a.Initialize() 22 | ensureTableExists() 23 | code := m.Run() 24 | clearTable() 25 | os.Exit(code) 26 | } 27 | 28 | func ensureTableExists() { 29 | if _, err := a.DB.Exec(tableCreationQuery); err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | 34 | func clearTable() { 35 | a.DB.Exec("DELETE FROM users") 36 | a.DB.Exec("ALTER TABLE users AUTO_INCREMENT = 1") 37 | } 38 | 39 | const tableCreationQuery = ` 40 | CREATE TABLE IF NOT EXISTS users 41 | ( 42 | id INT AUTO_INCREMENT PRIMARY KEY, 43 | name VARCHAR(50) NOT NULL, 44 | age INT NOT NULL 45 | )` 46 | 47 | func TestEmptyTable(t *testing.T) { 48 | clearTable() 49 | 50 | req, _ := http.NewRequest("GET", "/users", nil) 51 | response := executeRequest(req) 52 | 53 | checkResponseCode(t, http.StatusOK, response.Code) 54 | 55 | if body := response.Body.String(); body != "[]" { 56 | t.Errorf("Expected an empty array. Got %s", body) 57 | } 58 | } 59 | 60 | func executeRequest(req *http.Request) *httptest.ResponseRecorder { 61 | rr := httptest.NewRecorder() 62 | a.Router.ServeHTTP(rr, req) 63 | 64 | return rr 65 | } 66 | 67 | func checkResponseCode(t *testing.T, expected, actual int) { 68 | if expected != actual { 69 | t.Errorf("Expected response code %d. Got %d\n", expected, actual) 70 | } 71 | } 72 | 73 | func TestGetNonExistentUser(t *testing.T) { 74 | clearTable() 75 | 76 | req, _ := http.NewRequest("GET", "/user/45", nil) 77 | response := executeRequest(req) 78 | 79 | checkResponseCode(t, http.StatusNotFound, response.Code) 80 | 81 | var m map[string]string 82 | json.Unmarshal(response.Body.Bytes(), &m) 83 | if m["error"] != "User not found" { 84 | t.Errorf("Expected the 'error' key of the response to be set to 'User not found'. Got '%s'", m["error"]) 85 | } 86 | } 87 | 88 | func TestCreateUser(t *testing.T) { 89 | clearTable() 90 | 91 | payload := []byte(`{"name":"test user �","age":30}`) 92 | 93 | req, _ := http.NewRequest("POST", "/user", bytes.NewBuffer(payload)) 94 | response := executeRequest(req) 95 | 96 | checkResponseCode(t, http.StatusCreated, response.Code) 97 | 98 | var m map[string]interface{} 99 | json.Unmarshal(response.Body.Bytes(), &m) 100 | 101 | if m["name"] != "test user �" { 102 | t.Errorf("Expected user name to be 'test user'. Got '%v'", m["name"]) 103 | } 104 | 105 | if m["age"] != 30.0 { 106 | t.Errorf("Expected user age to be '30'. Got '%v'", m["age"]) 107 | } 108 | 109 | // the id is compared to 1.0 because JSON unmarshaling converts numbers to 110 | // floats, when the target is a map[string]interface{} 111 | if m["id"] != 1.0 { 112 | t.Errorf("Expected product ID to be '1'. Got '%v'", m["id"]) 113 | } 114 | } 115 | 116 | func addUsers(count int) { 117 | if count < 1 { 118 | count = 1 119 | } 120 | 121 | for i := 0; i < count; i++ { 122 | statement := fmt.Sprintf("INSERT INTO users(name, age) VALUES('%s', %d)", ("User " + strconv.Itoa(i+1)), ((i + 1) * 10)) 123 | a.DB.Exec(statement) 124 | } 125 | } 126 | 127 | func TestGetUser(t *testing.T) { 128 | clearTable() 129 | addUsers(1) 130 | 131 | req, _ := http.NewRequest("GET", "/user/1", nil) 132 | response := executeRequest(req) 133 | 134 | checkResponseCode(t, http.StatusOK, response.Code) 135 | } 136 | 137 | func TestUpdateUser(t *testing.T) { 138 | clearTable() 139 | addUsers(1) 140 | 141 | req, _ := http.NewRequest("GET", "/user/1", nil) 142 | response := executeRequest(req) 143 | var originalUser map[string]interface{} 144 | json.Unmarshal(response.Body.Bytes(), &originalUser) 145 | 146 | payload := []byte(`{"name":"test user - updated name","age":21}`) 147 | 148 | req, _ = http.NewRequest("PUT", "/user/1", bytes.NewBuffer(payload)) 149 | response = executeRequest(req) 150 | 151 | checkResponseCode(t, http.StatusOK, response.Code) 152 | 153 | var m map[string]interface{} 154 | json.Unmarshal(response.Body.Bytes(), &m) 155 | 156 | if m["id"] != originalUser["id"] { 157 | t.Errorf("Expected the id to remain the same (%v). Got %v", originalUser["id"], m["id"]) 158 | } 159 | 160 | if m["name"] == originalUser["name"] { 161 | t.Errorf("Expected the name to change from '%v' to '%v'. Got '%v'", originalUser["name"], m["name"], m["name"]) 162 | } 163 | 164 | if m["age"] == originalUser["age"] { 165 | t.Errorf("Expected the age to change from '%v' to '%v'. Got '%v'", originalUser["age"], m["age"], m["age"]) 166 | } 167 | } 168 | 169 | func TestDeleteUser(t *testing.T) { 170 | clearTable() 171 | addUsers(1) 172 | 173 | req, _ := http.NewRequest("GET", "/user/1", nil) 174 | response := executeRequest(req) 175 | checkResponseCode(t, http.StatusOK, response.Code) 176 | 177 | req, _ = http.NewRequest("DELETE", "/user/1", nil) 178 | response = executeRequest(req) 179 | 180 | checkResponseCode(t, http.StatusOK, response.Code) 181 | 182 | req, _ = http.NewRequest("GET", "/user/1", nil) 183 | response = executeRequest(req) 184 | checkResponseCode(t, http.StatusNotFound, response.Code) 185 | } 186 | -------------------------------------------------------------------------------- /model.go: -------------------------------------------------------------------------------- 1 | // model.go 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/jmoiron/sqlx" 7 | ) 8 | 9 | func (u *user) getUser(db *sqlx.DB) error { 10 | err := db.Get(u, "SELECT * FROM users WHERE id=?", u.ID) 11 | return err 12 | } 13 | 14 | func (u *user) updateUser(db *sqlx.DB) error { 15 | _, err := db.NamedExec(`UPDATE users SET name=:name, age=:age WHERE id = :id`, u) 16 | return err 17 | } 18 | 19 | func (u *user) deleteUser(db *sqlx.DB) error { 20 | _, err := db.Exec("DELETE FROM users WHERE id=?", u.ID) 21 | return err 22 | } 23 | 24 | func (u *user) createUser(db *sqlx.DB) error { 25 | result, err := db.NamedExec(`INSERT INTO users (name, age) VALUES (:name, :age)`, u) 26 | if err != nil { 27 | return err 28 | } 29 | id, err := result.LastInsertId() 30 | u.ID = int(id) 31 | return err 32 | } 33 | 34 | func getUsers(db *sqlx.DB, startid, count int) (users []user, err error) { 35 | err = db.Select(&users, "SELECT * FROM users WHERE id >= ? ORDER BY id LIMIT ?", startid, count) 36 | if err != nil { 37 | return users, err 38 | } 39 | if len(users) == 0 { 40 | return []user{}, nil 41 | } 42 | return users, nil 43 | } 44 | -------------------------------------------------------------------------------- /sql/users.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE rest_api_example character set utf8mb4 collate utf8mb4_unicode_ci; 2 | USE rest_api_example; 3 | CREATE TABLE users ( 4 | id INT AUTO_INCREMENT PRIMARY KEY, 5 | name VARCHAR(50) NOT NULL, 6 | age INT 7 | ); 8 | -------------------------------------------------------------------------------- /test-connect.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # assuming mariadb is set to localhost in your /etc/hosts 3 | result=$(echo 'show create database rest_api_example\G;' | mysql -h mariadb -P 3306 -u root --password=secret) 4 | 5 | if ! echo "$result" | grep utf8mb4_unicode_ci 6 | then 7 | echo FAIL, utf8mb4_unicode_ci not found 8 | fi 9 | 10 | 11 | result=$(echo 'show create table users\G;' | mysql -h mariadb -P 3306 -u root --password=secret rest_api_example) 12 | if ! echo "$result" | grep utf8mb4_unicode_ci 13 | then 14 | echo FAIL, utf8mb4_unicode_ci not found 15 | fi 16 | 17 | 18 | --------------------------------------------------------------------------------