最近Goに入門しました。
勉強のため外部パッケージなしの手作りREST API立ててみました。
フルスクラッチでRESTってあまりやっている人がいなかったので記事にしてみます。(go-json-restはいっぱいあったんですが)
入門者なので、イケてない部分があれば(優しく)教えてください。
やりたいこと
標準パッケージ("net/http")だけでREST APIを立てる。
外部パッケージは使わない。
最も単純な実装
ハンドラの中でr.Method
条件分けすればOK。
func main() {
http.HandleFunc("/", restHandler)
http.ListenAndServe(":8080", nil)
}
func restHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
fmt.Fprintln(w, "GET called!!")
} else if r.Method == "POST" {
fmt.Fprintln(w, "POST called!!")
} else if r.Method == "PUT" {
fmt.Fprintln(w, "PUT called!!")
} else if r.Method == "DELETE" {
fmt.Fprintln(w, "DELETE called!!")
}
}
$ curl -X GET localhost:8080
GET called!!
$ curl -X POST localhost:8080
POST called!!
$ curl -X PUT localhost:8080
PUT called!!
$ curl -X DELETE localhost:8080
DELETE called!!
Todoリスト用APIの実装
上で終わり、あとは各自好きなようにハンドラ書いてね、っていうのは記事としていかがかと思うので、簡単なTodoAPIを立てて、Nuxt.jsで以前作ったフロントエンドから操作できるようにしてみました。
API仕様
エンドポイント | リクエストメソッド | 動作 |
---|---|---|
/items |
GET | 全てのタスクの取得 |
/items |
POST | 新規アイテムの追加 |
/items |
DELETE | 実行済み全タスクの削除 |
/items/:id |
DELETE | 1つのタスクを削除 |
/items/:id/done |
PUT | 1つのタスクを実行済みにする |
/items/:id/done |
DELETE | 1つのタスクを実行済みに変更 |
テーブル
TodoのItemsテーブルに以下の項目を用意。
- id (主キー)
- name (Todoアイテムの名前)
- done (実行済みか否か)
// 構造体
type Item struct {
Id int `json:"id"`
Name string `json:"name"`
Done bool `json:"done"`
}
// テーブル
func initDB(db *sql.DB) error {
const sql = `
CREATE TABLE IF NOT EXISTS items (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT 0
);`
_, err := db.Exec(sql)
return err
}
リクエストハンドラ
2つのルート/items
、/items/
(/items/:id
と/items/:id/done
)のそれぞれにハンドラ関数を設定しました。各ハンドラの中でルートパラメタとリクエストメソッドで場合わけし、データベース操作を行います。
func main() {
// (略)データベース初期化
// リクエストハンドラの追加
http.HandleFunc("/items", itemsHandler) // `/items`の処理()
http.HandleFunc("/items/", itemsIdHandler) // `/items/:id`と`/items/:id/done`の処理
err = http.ListenAndServe(":4000", nil)
if err != nil {
log.Fatal(err)
}
}
/*** リクエストハンドラ ***/
func itemsHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
getAllItems(w, r) // 全てのitemの取得
case "POST":
addNewItem(w, r) // 新しいitemの追加
case "DELETE":
deleteDoneItems(w) // 実行済みitemの削除
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
}
func itemsIdHandler(w http.ResponseWriter, r *http.Request) {
// ルートパラメータの取得(例: `/items/1/done` -> ["items", "1", "done"])
params := getRouteParams(r)
if len(params) < 2 || len(params) > 3 {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
// itemのidをintで取得
id, err := strconv.Atoi(params[1])
if err != nil || id < 1 {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if len(params) == 2 {
updateItem(id, w, r)
} else if params[2] == "done" {
updateDone(id, w, r)
} else {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
func updateItem(id int, w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "DELETE":
deleteOneItem(id, w)
default:
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
func updateDone(id int, w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "PUT":
doneItem(id, w, r)
case "DELETE":
unDoneItem(id, w, r)
default:
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
データベースの操作はこんな感じです。
// 全アイテムの取得
func getAllItems(w http.ResponseWriter, r *http.Request) {
var items []Item
rows, err := db.Query(`SELECT * FROM items;`)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
for rows.Next() {
var item Item
rows.Scan(&item.Id, &item.Name, &item.Done)
items = append(items, item)
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
if err := enc.Encode(items); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, buf.String())
}
// 新しいアイテムを追加
func addNewItem(w http.ResponseWriter, r *http.Request) {
var reqBody struct {
Name string `json:"name"`
}
dec := json.NewDecoder(r.Body)
err := dec.Decode(&reqBody)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
_, err = db.Exec(`INSERT INTO items (name, done) values (?, ?)`, reqBody.Name, false)
if err != nil {
log.Print(err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
w.WriteHeader(http.StatusCreated)
}
// 1つのアイテムを削除
func deleteOneItem(id int, w http.ResponseWriter) {
_, err := db.Exec(`DELETE FROM items WHERE id=?`, id)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
}
// 全ての実行済みアイテムを削除
func deleteDoneItems(w http.ResponseWriter) {
_, err := db.Exec(`DELETE FROM items WHERE done=true`)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
}
// アイテムを実行済みにする
func doneItem(id int, w http.ResponseWriter, r *http.Request) {
if id == -1 {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
_, err := db.Exec(`UPDATE items SET done=true where id=?`, id)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
w.WriteHeader(http.StatusCreated)
}
// アイテムを未実行にする
func unDoneItem(id int, w http.ResponseWriter, r *http.Request) {
if id == -1 {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
_, err := db.Exec(`UPDATE items SET done=false where id=?`, id)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
}
ちなみにルートパラメータの取得もいい関数が見当たらなかったのでstrings.Split
などを使って自分で書きました。(イケてるかあまり自信ない。)
func getRouteParams(r *http.Request) []string {
splited := strings.Split(r.RequestURI, "/")
var params []string
for i := 0; i < len(splited); i++ {
if len(splited[i]) != 0 {
params = append(params, splited[i])
}
}
return params
}
動作確認
curlで動作確認。
# タスクを5つ追加
$ curl -d '{"name":"item1"}' localhost:4000/items
$ curl -d '{"name":"item2"}' localhost:4000/items
$ curl -d '{"name":"item3"}' localhost:4000/items
$ curl -d '{"name":"item4"}' localhost:4000/items
$ curl -d '{"name":"item5"}' localhost:4000/items
# 全タスクの取得
$ curl localhost:4000/items
[{"id":1,"name":"item1","done":false},{"id":2,"name":"item2","done":false},{"id":3,"name":"item3","done":false},{"id":4,"name":"item4","done":false},{"id":5,"name":"item5","done":false}]
# item2, 3, 4を実行済みにする
$ curl -X PUT localhost:4000/items/2/done
$ curl -X PUT localhost:4000/items/3/done
$ curl -X PUT localhost:4000/items/4/done
$ curl localhost:4000/items
[{"id":1,"name":"item1","done":false},{"id":2,"name":"item2","done":true},{"id":3,"name":"item3","done":true},{"id":4,"name":"item4","done":true},{"id":5,"name":"item5","done":false}]
# item3を未実行にする
$ curl -X DELETE localhost:4000/items/3/done
$ curl localhost:4000/items
[{"id":1,"name":"item1","done":false},{"id":2,"name":"item2","done":true},{"id":3,"name":"item3","done":false},{"id":4,"name":"item4","done":true},{"id":5,"name":"item5","done":false}]
# 実行済みを全て削除する
$ curl -X DELETE localhost:4000/items
$ curl localhost:4000/items/
[{"id":1,"name":"item1","done":false},{"id":3,"name":"item3","done":false},{"id":5,"name":"item5","done":false}]
# アイテムを1つ削除する
$ curl -X DELETE localhost:4000/items/3
$ curl localhost:4000/items
[{"id":1,"name":"item1","done":false},{"id":5,"name":"item5","done":false}]
基本的なCRUDはうまくいっているようです。
CORSの対応
curl叩けば行くのですが、ブラウザからaxios経由でやるとうまくいきません。
CORS(オリジン間リソース共有)を許可するために、レスポンスヘッダを追加します。
- オリジン間アクセスを許可する
Access-Control-Allow-Origin
ヘッダ。 - axios(の中で呼ばれているXMLHttpRequest)がPOST/PUT/DELETEのリクエストする前にはOPTIONSリクエストが飛んでくる(Pre-Flightリクエスト)それに対する
Access-Control-Allow-Headers
とAccess-Control-Allow-Methods
ヘッダ。
func itemsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") // localhost:3000からのオリジン間アクセスを許可する
switch r.Method {
case "GET":
getAllItems(w, r) // 全てのitemの取得
case "POST":
addNewItem(w, r) // 新しいitemの追加
case "DELETE":
deleteDoneItems(w) // 実行済みitemの削除
case "OPTIONS":
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") // Content-Typeヘッダの使用を許可する
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") // pre-flightリクエストに対応する
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
}
func itemsIdHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") // localhost:3000からのオリジン間アクセスを許可する
// ルートパラメータの取得(例: `/items/1/done` -> ["items", "1", "done"])
params := getRouteParams(r)
if len(params) < 2 || len(params) > 3 {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
// itemのidをintで取得
id, err := strconv.Atoi(params[1])
if err != nil || id < 1 {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if len(params) == 2 {
updateItem(id, w, r)
} else if params[2] == "done" {
updateDone(id, w, r)
} else {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
func updateItem(id int, w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "DELETE":
deleteOneItem(id, w)
case "OPTIONS":
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") // Content-Typeヘッダの使用を許可する
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") // pre-flightリクエストに対応する
default:
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
func updateDone(id int, w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "PUT":
doneItem(id, w, r)
case "DELETE":
unDoneItem(id, w, r)
case "OPTIONS":
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") // Content-Typeヘッダの使用を許可する
w.Header().Set("Access-Control-Allow-Methods", "PUT, DELETE, OPTIONS") // pre-flightリクエストに対応する
default:
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
できました。
まとめ
Goのnet/httpパッケージのみで簡単なREST APIを立ててみました。フルスクラッチでも割と簡単にかけてしまうので良いですね。
今、42Tokyoの課題でC++のフルスクラッチWebサーバも開発しているのですが手軽さでは比較になりませんね。標準パッケージでこんなに簡単に書けて素晴らしい。(net/httpの再実装をしたらすごく勉強になりそう)
完全なるコードはこちら。改善点などあればコメントいただけますと幸いです。
API:https://github.com/dai65527/go_simpleREST
フロントエンド:https://github.com/dai65527/tstodo-client