Go Todo練習 day2

Posted on
go try

前回の続きということでday2です

  • TODOにUpdate, Deleteを追加
  • Packageを分ける

CRUDなTODOになったと思います。

作成したコードはsourjp/go-practice/day2にあります。

構成

少ない内容であればMVCで分ける方がわかりやすいですね。

$ tree
.
├── Makefile
├── controllers
│   ├── router.go
│   └── todo.go
├── go.mod
├── go.sum
├── main.go
├── models
│   ├── db.go
│   └── todo.go
└── todo.sql

package models

todo.sql

CREATE TABLE IF NOT EXISTS todo (
    id SERIAL NOT NULL PRIMARY KEY,
    title VARCHAR(25),
    message VARCHAR(255),
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    finished_at TIMESTAMP
)

models/db.go

package models

import (
	"database/sql"
	"fmt"
)

func NewDB() (*sql.DB, error) {
    // paramsはconfファイルからそのうち読めるようにします
	var (
		driver   = "postgres"
		host     = "localhost"
		user     = "root"
		password = "root"
		dbname   = "todo"
		sslmode  = "disable"

		params = fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=%s", host, user, password, dbname, sslmode)
	)
	db, err := sql.Open(driver, params)
	if err != nil {
		return nil, err
	}
	if err := db.Ping(); err != nil {
		return nil, err
	}
	return db, nil
}

models/todo.go

package models

import (
	"database/sql"
	"fmt"
	"time"
)

type TODOList struct {
	db *sql.DB
}

func NewTODOList(db *sql.DB) *TODOList {
	return &TODOList{db: db}
}

type TODO struct {
	ID         int        `db:"id" json:"id"`
	Title      string     `db:"title" json:"title"`
	Message    string     `db:"message" json:"message"`
    CreatedAt  time.Time  `db:"created_at" json:"created_at"`
    // pointer型にすることでから文字判定ができるようにしomitemptyが聞くようになる
	UpdatedAt  *time.Time `db:"updated_at" json:"updated_at,omitempty"`
	FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
}

func (tl *TODOList) Create(t TODO) error {
	if t.CreatedAt.IsZero() {
		t.CreatedAt = time.Now()
	}
	fmt.Println(t)
	const sql = "INSERT INTO todo (title, message, created_at, finished_at) VALUES ($1, $2, $3, $4)"
	_, err := tl.db.Exec(sql, t.Title, t.Message, t.CreatedAt, t.FinishedAt)
	if err != nil {
		return err
	}

	return nil
}

func (tl *TODOList) Get(id int) (TODO, error) {
	var t TODO
	const sql = "SELECT * FROM todo WHERE id = $1"
	err := tl.db.QueryRow(sql, id).Scan(&t.ID, &t.Title, &t.Message, &t.CreatedAt, &t.UpdatedAt, &t.FinishedAt)
	if err != nil {
		return TODO{}, err
	}
	return t, nil
}

func (tl *TODOList) GetItems(limit int) ([]TODO, error) {
	const sql = "SELECT * FROM todo ORDER BY id DESC LIMIT $1"
	rows, err := tl.db.Query(sql, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var todos []TODO
	for rows.Next() {
		var t TODO
		rows.Scan(&t.ID, &t.Title, &t.Message, &t.CreatedAt, &t.UpdatedAt, &t.FinishedAt)
		todos = append(todos, t)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return todos, nil
}

func (tl *TODOList) Update(t TODO, id int) error {
    const sql = "UPDATE todo SET title = $1, message = $2, updated_at = $3 WHERE id = $4"

// t.UpdatedAtにはpointerで渡す。もっとスッキリした書き方はないのかな?
	updatedAt := time.Now()
	t.UpdatedAt = &updatedAt
	_, err := tl.db.Exec(sql, t.Title, t.Message, t.UpdatedAt, id)
	if err != nil {
		return err
	}
	return err
}

func (tl *TODOList) Delete(id int) error {
	const sql = "DELETE FROM todo WHERE id = $1"
	_, err := tl.db.Exec(sql, id)
	if err != nil {
		return err
	}
	return nil
}

package controllers

controllers/router.go

package controllers

import (
	"log"

	"github.com/gin-gonic/gin"
	"github.com/sourjp/go-practice/day2/models"
)

func Router() {
	r := gin.Default()

	db, err := models.NewDB()
	if err != nil {
        // DBが読み込めなかったら起動して欲しくないのでFatal
		log.Fatal(err)
	}

	v1 := r.Group("/api/v1/todo")
	{
		th := NewTODOHandler(models.NewTODOList(db))
		v1.GET("/getitems", th.GetItems)
		v1.GET("/get/:id", th.Get)
		v1.POST("/create", th.Create)
		v1.PUT("/update/:id", th.Update)
		v1.DELETE("/delete/:id", th.Delete)
	}
	r.Run(":8080")
}

controllers/todo.go

package controllers

import (
	"log"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/sourjp/go-practice/day2/models"
)

// resWithErr で errorをまとめるようにしたが、ちょっとダサい。
func resWithErr(c *gin.Context, code int, message interface{}) {
	c.AbortWithStatusJSON(code, gin.H{"error": message})
}

func resNoErr(c *gin.Context, code int, message interface{}) {
	c.JSON(code, gin.H{"status": message})
}

type TODOHandler struct {
	tl *models.TODOList
}

func NewTODOHandler(tl *models.TODOList) *TODOHandler {
	return &TODOHandler{tl: tl}
}

func (th *TODOHandler) GetItems(c *gin.Context) {
	ls, ok := c.GetQuery("limit")
	if !ok {
		ls = "10"
	}
	li, err := strconv.Atoi(ls)
	if err != nil {
		log.Println(err)
		resWithErr(c, http.StatusBadRequest, "failed to parse your query")
		return
	}
	todos, err := th.tl.GetItems(li)
	if err != nil {
		log.Println(err)
		resWithErr(c, http.StatusInternalServerError, "failed to get items")
		return
	}
	if len(todos) == 0 {
		resNoErr(c, http.StatusOK, "there is no todos. create your todo!")
		return
	}
	c.JSON(http.StatusOK, todos)
}

func (th *TODOHandler) Get(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Println(err)
		resWithErr(c, http.StatusBadRequest, "failed to parse URI")
		return
	}

	todo, err := th.tl.Get(id)
	if err != nil {
		log.Println(err)
		resWithErr(c, http.StatusInternalServerError, "failed to get item")
		return
	}

	c.JSON(http.StatusOK, todo)
}

func (th *TODOHandler) Create(c *gin.Context) {
	var t models.TODO
	if err := c.ShouldBindJSON(&t); err != nil {
		log.Println(err)
		resWithErr(c, http.StatusBadRequest, "failed to parse your request")
		return
	}

	if err := th.tl.Create(t); err != nil {
		log.Println(err)
		resWithErr(c, http.StatusInternalServerError, "failed to create item")
		return
	}
	resNoErr(c, http.StatusOK, "created")
}

func (th *TODOHandler) Update(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Println(err)
		resWithErr(c, http.StatusBadRequest, "failed to parse URI")
		return
	}

	var t models.TODO
	if err := c.ShouldBindJSON(&t); err != nil {
		log.Println(err)
		resWithErr(c, http.StatusBadRequest, "failed to parse your request")
		return
	}

	if err := th.tl.Update(t, id); err != nil {
		log.Println(err)
		resWithErr(c, http.StatusInternalServerError, "failed to update item")
		return
	}

	resNoErr(c, http.StatusOK, "updated")
}

func (th *TODOHandler) Delete(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Println(err)
		resWithErr(c, http.StatusBadRequest, "failed to parse URI")
		return
	}

	if err := th.tl.Delete(id); err != nil {
		log.Println(err)
		resWithErr(c, http.StatusInternalServerError, "failed to delete item")
	}

	resNoErr(c, http.StatusOK, "deleted")
}

package main

main.go

package main

import (
	_ "github.com/lib/pq"
	"github.com/sourjp/go-practice/day2/controllers"
)

func main() {
	controllers.Router()
}

テスト

$ make test
# 登録データなし
curl localhost:8080/api/v1/todo/getitems
{"status":"there is no todos. create your todo!"}

# 3つTODOを登録
curl -X POST localhost:8080/api/v1/todo/create -d '{"title": "yesterday", "message": "cooked"}'
{"status":"created"}
curl -X POST localhost:8080/api/v1/todo/create -d '{"title": "today", "message": "cook"}'
{"status":"created"}
curl -X POST localhost:8080/api/v1/todo/create -d '{"title": "tomorrow", "message": "will cook"}'
{"status":"created"}

# TODO #3を更新
curl localhost:8080/api/v1/todo/get/3
{"id":3,"title":"tomorrow","message":"will cook","created_at":"2020-08-22T16:55:22.235317Z"}
curl -X PUT localhost:8080/api/v1/todo/update/3 -d '{"title": "tomorrow", "message": "will not cook"}'
{"status":"updated"}
curl localhost:8080/api/v1/todo/get/3
{"id":3,"title":"tomorrow","message":"will not cook","created_at":"2020-08-22T16:55:22.235317Z","updated_at":"2020-08-22T16:55:22.310527Z"}

# TODO #1を削除
curl -X DELETE localhost:8080/api/v1/todo/delete/1
{"status":"deleted"}

# TODO の結果を確認
curl localhost:8080/api/v1/todo/getitems
[{"id":3,"title":"tomorrow","message":"will not cook","created_at":"2020-08-22T16:55:22.235317Z","updated_at":"2020-08-22T16:55:22.310527Z"},{"id":2,"title":"today","message":"cook","created_at":"2020-08-22T16:55:22.193637Z"}]

# TODOの表示数をqueryで変更
curl localhost:8080/api/v1/todo/getitems?limit=1
[{"id":3,"title":"tomorrow","message":"will not cook","created_at":"2020-08-22T16:55:22.235317Z","updated_at":"2020-08-22T16:55:22.310527Z"}]


todo=# select * from todo;
 id |  title   |    message    |         created_at         |         updated_at         | finished_at 
----+----------+---------------+----------------------------+----------------------------+-------------
  2 | today    | cook          | 2020-08-22 16:55:22.193637 |                            | 
  3 | tomorrow | will not cook | 2020-08-22 16:55:22.235317 | 2020-08-22 16:55:22.310527 | 
(2 rows)

まとめ

前回と比較するとここが組み込めたかと思います。

  • TODOにUpdate, Deleteを追加
  • Packageを分ける

次の課題はここら辺から抽出して組み込もうと思います。

  • 設定をconfファイルから読み込むようにする
  • Graceful shutdownを入れる
  • 自作Errorを用意する
  • Testを書く
  • User機能(signup/login/logout)あたりを追加する
  • Middleware - Authを追加する