koyebで作るgoのTODOアプリ

koyebが個人的に熱い
herokuに替わるおもちゃが欲しかった今日この頃
（herokuがなくなってからしばらく彷徨い続けて、netlifyやrenderなど試して、ようやくkoyebに辿り着いた）
koyebはいいぞぉ〜

普段はAWSで遊んでいるけど、個人で何かWebアプリを作るなら可能な限り無料で作りたい
そう言う時に、koyebは最高のおもちゃ

https://www.koyeb.com/

# koyeb について

koyebは以下のプラットフォームを提供します

- すべてのアプリの展開を管理するための使いやすいウェブインターフェース:無料でアカウントを作成してください
- 完全な Web アプリケーション、静的および動的サイト、API、バックグラウンド ワーカーなど、あらゆる種類のサービスをサポートします
- Docker コンテナを完全にサポートします
- Git 駆動型のデプロイメントにより、Ruby、Node.js、Java、Python、Clojure、Scala、Go、Rust、PHP、またはリポジトリ内の Dockerfile を使用してネイティブ コードをビルドおよびデプロイします
- グローバル CDN とゾーン間の強力な負荷分散を備えた高性能エッジ ネットワーク
- フルサービスのメッシュと検出により、数秒で安全なマイクロサービスを展開できます
- 高速で安全な MicroVM での透過的な展開
- ターミナルから直接リソースを管理し、自動化するための Koyeb CLI (コマンド ライン インターフェイス) 
Koyeb をプログラムで使用するための使いやすい REST API。APIドキュメントをご覧ください。

引用元：
https://www.koyeb.com/docs


...つまり、最強じゃね？(語彙力皆無)

# 作るモノ
CRUDを備えたTODOアプリを作る

## 機能一覧
| 機能 | パス | メソッド |
| ---- | ---- | ---- |
| TODO一覧取得 | /todos | GET |
| TODO作成 | /todos | POST |
| TODO取得 | /todos/{id} | GET |
| TODO更新 | /todos/{id} | PUT |
| TODO削除 | /todos/{id} | DELETE |

# プログラム準備・koyebサービス作成
**注意**
※このコードはサンプルコードにつき、HTTPリクエストされてきた値のバリデーションは行なっておりません。実務で使用する場合は値のバリデーションは必須要件のため、必ず実装するようにしてください。
## プログラム準備
今回使用するコードのディレクトリ構成は以下の通り
```shell
tree .
.
├── Dockerfile
├── go.mod
├── go.sum
├── main.go
└── todo
    └── todo.go

2 directories, 5 files
```
ソースコード

https://github.com/sokorahen-szk/sample-koyeb-todo-for-go

### Dockerfile
golangのアプリケーションはDockerコンテナ上で動かすため、
Dockerfileを用意する。
今回は公式にあるコードを参考に作成する。
```diff:Dockerfile
- FROM golang:1.19-alpine AS builder
+ FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
- RUN go build -o ./example-golang ./main.go
+ RUN go build -o ./main ./main.go

FROM alpine:latest AS runner
WORKDIR /app
- COPY --from=builder /app/example-golang .
+ COPY --from=builder /app/main .
EXPOSE 8080
- ENTRYPOINT ["./example-golang"]
+ ENTRYPOINT ["./main"]
```
golangのバージョンとビルドして生成される実行ファイル名は変更した。

### main.go, todo/todo.go
```golang:main.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"sync"

	t "github.com/sokorahen-szk/sample-koyeb-todo-for-go/todo"
)

// NOTE: 簡略化のためメモリ上に保存する
var todos = t.NewTodoList()
var mu sync.Mutex

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		log.Fatal("PORT is not set")
	}
	server := http.Server{
		Addr:    fmt.Sprintf(":%s", port),
		Handler: nil,
	}

	http.HandleFunc("GET /todos", getTodos)
	http.HandleFunc("POST /todos", addTodo)
	http.HandleFunc("GET /todos/{id}", getTodo)
	http.HandleFunc("PUT /todos/{id}", updateTodo)
	http.HandleFunc("DELETE /todos/{id}", deleteTodo)

	log.Printf("Server is running on port: %s\n", port)

	if err := server.ListenAndServe(); err != nil {
		log.Fatalln(err)
	}
}

// getTodos TODOの一覧を取得する
func getTodos(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")

	res := struct {
		Todos []t.Todo `json:"todos"`
	}{
		Todos: todos.List(),
	}

	if err := json.NewEncoder(w).Encode(res); err != nil {
		log.Println(err)
	}
}

// addTodo TODOを追加する
func addTodo(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	defer mu.Unlock()

	w.Header().Set("Content-Type", "application/json; charset=utf-8")

	req := struct {
		Name        string `json:"name"`
		Description string `json:"description"`
	}{}

	b, err := io.ReadAll(r.Body)
	r.Body.Close()
	if err != nil {
		log.Println(err)
	}

	if err := json.Unmarshal(b, &req); err != nil {
		log.Println(err)
	}

	createdTodo := t.NewCreateTodoFactory(
		req.Name,
		req.Description,
	)

	if !todos.Add(createdTodo) {
		w.WriteHeader(http.StatusTooManyRequests)
		return
	}

	if err := json.NewEncoder(w).Encode(createdTodo); err != nil {
		log.Println(err)
	}
}

// deleteTodo TODOを削除する
func deleteTodo(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	defer mu.Unlock()

	w.Header().Set("Content-Type", "application/json; charset=utf-8")

	id := r.PathValue("id")
	if todos.Get(id) == nil {
		w.WriteHeader(http.StatusNotFound)
	}
	todos = todos.Remove(id)
}

// getTodo TODOを取得する
func getTodo(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")

	id := r.PathValue("id")
	todo := todos.Get(id)
	if todo == nil {
		w.WriteHeader(http.StatusNotFound)
	}

	if err := json.NewEncoder(w).Encode(todo); err != nil {
		log.Println(err)
	}
}

func updateTodo(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	defer mu.Unlock()

	w.Header().Set("Content-Type", "application/json; charset=utf-8")

	req := struct {
		Name        string `json:"name"`
		Description string `json:"description"`
		IsDone      bool   `json:"is_done"`
	}{}

	b, err := io.ReadAll(r.Body)
	r.Body.Close()
	if err != nil {
		log.Println(err)
	}

	if err := json.Unmarshal(b, &req); err != nil {
		log.Println(err)
	}

	id := r.PathValue("id")
	todo := todos.Get(id)
	if todo == nil {
		w.WriteHeader(http.StatusNotFound)
		return
	}

	updatedTodo := t.NewTodo(todo.ID, req.Name, req.Description, req.IsDone)
	if !todos.Update(updatedTodo) {
		w.WriteHeader(http.StatusNotAcceptable)
		return
	}

	if err := json.NewEncoder(w).Encode(updatedTodo); err != nil {
		log.Println(err)
	}
}
```
```golang:todo/todo.go
package todo

import "github.com/google/uuid"

type Todo struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	Description string `json:"description"`
	IsDone      bool   `json:"is_done"`
}

func NewTodo(id, name, description string, isDone bool) Todo {
	return Todo{
		ID:          id,
		Name:        name,
		Description: description,
		IsDone:      isDone,
	}
}

func NewCreateTodoFactory(name, description string) Todo {
	return NewTodo(uuid.NewString(), name, description, false)
}

type TodoList struct {
	items         []Todo
	maxTodoLength int
}

type TodoListOption func(*TodoList)

func NewTodoList(options ...TodoListOption) *TodoList {
	defaultTodoMaxLimit := 10
	tl := &TodoList{
		items:         make([]Todo, 0, defaultTodoMaxLimit),
		maxTodoLength: defaultTodoMaxLimit,
	}

	for _, opt := range options {
		opt(tl)
	}

	return tl
}

func (tl *TodoList) Count() int {
	return len(tl.items)
}

func (tl *TodoList) Add(todo Todo) bool {
	if tl.Count() <= tl.maxTodoLength {
		tl.items = append(tl.items, todo)

		return true
	}

	return false
}

func (tl *TodoList) Remove(id string) *TodoList {
	for idx, todo := range tl.items {
		if todo.ID == id {
			return NewTodoList(TodoListOption(func(t *TodoList) {
				t.items = tl.items[:idx+copy(tl.items[idx:], tl.items[idx+1:])]
			}))
		}
	}

	return NewTodoList(TodoListOption(func(t *TodoList) {
		t.items = tl.items
	}))
}

func (tl *TodoList) Update(todo Todo) bool {
	for idx, t := range tl.items {
		if t.ID == todo.ID {
			tl.items[idx] = todo

			return true
		}
	}

	return false
}

func (tl *TodoList) Get(id string) *Todo {
	for _, todo := range tl.items {
		if todo.ID == id {
			return &todo
		}
	}

	return nil
}

func (tl *TodoList) List() []Todo {
	unfinishedTodos := make([]Todo, 0)
	for _, todo := range tl.items {
		// 完了済みはTODOリストから除外される
		if todo.IsDone {
			continue
		}

		unfinishedTodos = append(unfinishedTodos, todo)
	}

	return unfinishedTodos
}

```

### go.mod
```go.mod
module github.com/sokorahen-szk/sample-koyeb-todo-for-go

go 1.23.2

require github.com/google/uuid v1.6.0 // indirect
```

## koyebサービス作成
### サービスタイプを選択
左メニューから「Create Service」をクリックし、「Web service」をクリックする。
今回はGithubのコードを使用してデプロイするため、「GitHub」をクリックする。

### GitHubのリポジトリを選択
リポジトリがプライベートの場合、Koyebに権限を与える必要がある。
今回は公開リポジトリを使用しているため、下部の「Public GitHub repository」にリポジトリのURLを入力する。その後、「Import」ボタンをクリックする。

### リソースとリージョンを選択
CPUは「CPU Eco」を選択し、FreeのCPUを選択する。
リージョンは「Washington, D.C」を選択する。
日本リージョンもあるが、Freeプランでは選択不可になっている。
選択できたら、「Next」をクリックする。

### 確認画面
ここでは特に設定が不要のため、そのまま下部の「Deploy」をクリックする。
![IMG1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987493/2443f6a0-1ace-6c06-c002-12411e4a99bc.png)

上記の「Deploy」押下後はしばらく待つ

![IMG2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987493/75317af7-d3ea-1f4a-935f-32e2d84be896.png)

Build => Completed
Deployment => Healthy

BuildとDeploymentがグリーンで正常になればOK

# 動作確認
## TODO作成
```shell
$ curl -X POST -H "Content-Type: application/json" \
> -d '{"name":"タスク1", "description":"これはサンプル"}' https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos | jq
{
    "id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
    "name": "タスク1",
    "description": "これはサンプル",
    "is_done": false
}
```
## TODO一覧取得
```shell
$ curl https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos | jq
{
  "todos": [
    {
      "id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
      "name": "タスク1",
      "description": "これはサンプル",
      "is_done": false
    },
    {
      "id": "50c4b8f2-dfbc-4f1a-9a95-42fb5e233997",
      "name": "タスク2",
      "description": "これはサンプル",
      "is_done": false
    }
  ]
}
```
## TODO取得
```shell
$ curl https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos/5e8bae42-ec36-44e7-8174-d08f4e16d9d4 | jq
{
  "id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
  "name": "タスク1",
  "description": "これはサンプル",
  "is_done": false
}
```
## TODO更新
```shell
$ curl -X PUT -H "Content-Type: application/json" \
> -d '{"name":"タスク3", "description":"これはサンプル"}' https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos/5e8bae42-ec36-44e7-8174-d08f4e16d9d4 | jq
{
    "id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
    "name": "タスク3",
    "description": "これはサンプル",
    "is_done": false
}
```
## TODO削除
```shell
$ curl -X DELETE https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos/5e8bae42-ec36-44e7-8174-d08f4e16d9d4
```

# 次回
次はRDBMS(Postgresql)を使ってTODOを永続化するようにアプリ改修した記事を出す

# 参考
- https://www.koyeb.com/docs/deploy/go
- https://www.koyeb.com/docs/build-and-deploy/build-from-git/golang