Goの学習がてらにTodoアプリを作成してみた(Go言語、MySQL、TypeScript、React)

## 初めに
以下記事は、環境変数を用いたデータベース接続(todoアプリケーションでの)についての記事ですが全体的なコードをこちらの記事で投稿しようと思います。

https://qiita.com/hrk_ym/items/3661b693c74278836e9d

## 構成
今回のアプリケーションは、以下の構成で実装します。

* GoでREST APIのサーバーを実装
* MySQLでデータの永続化を行う
* ReactとTypeScriptでフロントエンドを実装

▼ ディレクトリ構造

```
.
├── client
│   ├── src
│   │   ├── App.tsx
    │   ├── index.tsx
│   │   ├── components
│   │   │   └── TodoList.tsx
│   └── ...
├── server
│   ├── main.go
│   ├── handlers
│   │   ├── todo.go
│   ├── models
│   │   ├── todo.go
        ├── .env(隠しファイル)
│   └── ...
└── database
    └── init.sql

```

## サーバー＆データベース側のコード

▼ server/main.go

```diff_go
package main

import (
	"go-typescript-original-todo/handlers"
	"net/http"

	"github.com/rs/cors"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/todos", handlers.TodoHandler)

	handler := cors.New(cors.Options{
		AllowedOrigins: []string{"http://localhost:3000"},
		AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
		AllowedHeaders: []string{"*"},
	}).Handler(mux)

	http.ListenAndServe(":8080", handler)
}
```

▼ server/handlers/todo.go

```diff_go
package handlers

import (
	"encoding/json"
	"go-typescript-original-todo/models"
	"net/http"
	"strconv"
)

var todoModel = models.NewTodoModel()

func TodoHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		getTodos(w, r)
	case http.MethodPost:
		createTodo(w, r)
	case http.MethodPut:
		updateTodo(w, r)
	case http.MethodDelete:
		deleteTodo(w, r)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func getTodos(w http.ResponseWriter, r *http.Request) {
	// Get all todos from the model and return as JSON
	todos, err := todoModel.GetTodos()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(todos)
}

func createTodo(w http.ResponseWriter, r *http.Request) {
	// Create a new todo with the data from the request
	var todo models.Todo

	// Parse the JSON request body and decode it into the Todo struct
	err := json.NewDecoder(r.Body).Decode(&todo)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Use the model to create a new todo in the database
	err = todoModel.CreateTodo(&todo)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Set the response header and send the created todo as JSON
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(todo)
}

func updateTodo(w http.ResponseWriter, r *http.Request) {
	// Parse the JSON request body and decode it into the Todo struct
	var todo models.Todo
	err := json.NewDecoder(r.Body).Decode(&todo)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Get the ID from the query parameters
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		http.Error(w, "Missing 'id' parameter", http.StatusBadRequest)
		return
	}
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "Invalid 'id' parameter", http.StatusBadRequest)
		return
	}

	// Get the existing Todo
	existingTodo, err := todoModel.GetTodo(id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Update the fields of the existing Todo if they were provided in the request
	if todo.Title != "" {
		existingTodo.Title = todo.Title
	}
	if todo.Description != "" {
		existingTodo.Description = todo.Description
	}
	if todo.Completed != existingTodo.Completed {
		existingTodo.Completed = todo.Completed
	}

	// Use the model to update the todo in the database
	err = todoModel.UpdateTodo(id, existingTodo)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Set the response header and send the updated todo as JSON
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(existingTodo)
}

func deleteTodo(w http.ResponseWriter, r *http.Request) {
	// Delete an existing todo with the given ID
	idStr := r.URL.Query().Get("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	err = todoModel.DeleteTodo(id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}
```

▼ server/models/todo.go

```diff_go
package models

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/joho/godotenv"
)

type TodoModel struct {
	db *sql.DB
}

func NewTodoModel() *TodoModel {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	user := os.Getenv("DB_USER")
	password := os.Getenv("DB_PASSWORD")
	host := os.Getenv("DB_HOST")
	port := os.Getenv("DB_PORT")
	dbName := os.Getenv("DB_NAME")

	connectionString := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", user, password, host, port, dbName)

	db, err := sql.Open("mysql", connectionString)
	if err != nil {
		panic(err)
	}
	return &TodoModel{db: db}
}

type Todo struct {
	ID          int    `json:"id"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Completed   bool   `json:"completed"`
	CreatedAt   string `json:"created_at"`
	UpdatedAt   string `json:"updated_at"`
}

func (t *TodoModel) GetTodos() ([]Todo, error) {
	// Get all todos from the database
	query := "SELECT id, title, description, completed, created_at, updated_at FROM todos"
	rows, err := t.db.Query(query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var todos []Todo
	for rows.Next() {
		var todo Todo
		err := rows.Scan(&todo.ID, &todo.Title, &todo.Description, &todo.Completed, &todo.CreatedAt, &todo.UpdatedAt)
		if err != nil {
			return nil, err
		}
		todos = append(todos, todo)
	}

	if err = rows.Err(); err != nil {
		return nil, err
	}

	return todos, nil
}

func (t *TodoModel) CreateTodo(todo *Todo) error {
	// Insert a new todo into the database
	currentTime := time.Now().Format("2006-01-02 15:04:05")
	todo.CreatedAt = currentTime
	todo.UpdatedAt = currentTime
	query := "INSERT INTO todos (title, description, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
	result, err := t.db.Exec(query, todo.Title, todo.Description, todo.Completed, todo.CreatedAt, todo.UpdatedAt)
	if err != nil {
		return err
	}

	// Retrieve the ID of the newly inserted todo
	id, err := result.LastInsertId()
	if err != nil {
		return err
	}
	todo.ID = int(id)

	return nil
}

func (t *TodoModel) UpdateTodo(id int, todo *Todo) error {
	// Update an existing todo in the database
	currentTime := time.Now().Format("2006-01-02 15:04:05")
	todo.UpdatedAt = currentTime
	query := "UPDATE todos SET title = ?, description = ?, completed = ?, updated_at = ? WHERE id = ?"
	_, err := t.db.Exec(query, todo.Title, todo.Description, todo.Completed, todo.UpdatedAt, id)
	if err != nil {
		return err
	}
	return nil
}

func (t *TodoModel) DeleteTodo(id int) error {
	// Delete an existing todo from the database
	query := "DELETE FROM todos WHERE id = ?"
	_, err := t.db.Exec(query, id)
	if err != nil {
		return err
	}
	return nil
}

func (t *TodoModel) GetTodo(id int) (*Todo, error) {
	// Get a todo from the database by id
	query := "SELECT id, title, description, completed, created_at, updated_at FROM todos WHERE id = ?"
	row := t.db.QueryRow(query, id)

	var todo Todo
	err := row.Scan(&todo.ID, &todo.Title, &todo.Description, &todo.Completed, &todo.CreatedAt, &todo.UpdatedAt)
	if err != nil {
		return nil, err
	}

	return &todo, nil
}
```

▼ database/init.sql

```diff_sql
CREATE DATABASE IF NOT EXISTS todo_app;

USE todo_app;

CREATE TABLE IF NOT EXISTS todos (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  description TEXT,
  completed BOOLEAN NOT NULL DEFAULT 0,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```

## フロント側のコード

▼ client/src/App.tsx

```diff_typescript
import TodoList from "./components/TodoList";

function App() {
  return (
    <>
      <TodoList />
    </>
  )
}
export default App;
```

▼ client/src/components/TodoList.tsx

```diff_typescript
import { useEffect, useState } from "react";
import axios from "axios";
import './TodoList.css';

interface Todo {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  created_at: string;
  updated_at: string;
}

enum TodoView {
  All,
  Completed,
  Uncompleted,
}

const TodoList = () => {
  const [todos, setTodos] = useState<Todo[] | null>([]);
  const [todoView, setTodoView] = useState<TodoView>(TodoView.All);
  const [isMaxTodos, setIsMaxTodos] = useState(false);
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [updatingTodoId, setUpdatingTodoId] = useState<number | null>(null);

  useEffect(() => {
    fetchTodos();
  }, []);

  const fetchTodos = () => {
    axios.get('http://localhost:8080/todos')
    .then((response) => {
      setTodos(response.data);
      setIsMaxTodos(response.data >= 10);
    })
    .catch((error) => {
      console.log('Error fetching todos:', error);
      setTodos(null);
    });
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (isMaxTodos) {
      alert('Cannot add more than 10 todos');
      return;
    }
    if (updatingTodoId) {
      axios.put(`http://localhost:8080/todos?id=${updatingTodoId}`, {
        title,
        description,
        completed: false,
      })
      .then(() => {
        setTitle("");
        setDescription("");
        setUpdatingTodoId(null);
        fetchTodos();
      })
      .catch((error) => {
        console.error('Error updating todo:', error);
      });
    } else {
      axios.post('http://localhost:8080/todos', {
        title,
        description,
        completed: false,
      })
      .then(() => {
        setTitle("");
        setDescription("");
        fetchTodos();
      })
      .catch((error) => {
        console.error('Error creating todo:', error);
      });
    }
  };

  const handleDelete = (id: number) => {
    axios.delete(`http://localhost:8080/todos?id=${id}`)
    .then(() => {
      fetchTodos();
    })
    .catch((error) => {
      console.error("Error deleting todo:", error);
      alert(`Error deleting todo: ${error.message || 'Unknown error'}`);
    });
  };

  const handleUpdate = (todo: Todo) => {
    setUpdatingTodoId(todo.id);
    setTitle(todo.title)
    setDescription(todo.description);
  };

  const handleToggleCompleted = (id: number, completed: boolean) => {
    axios.put(`http://localhost:8080/todos?id=${id}`, {
      completed: !completed,
    })
    .then(() => {
      fetchTodos();
    })
    .catch((error) => {
      console.error('Error updating todo:', error);
    })
  };

  const showAllTodos = () => {
    setTodoView(TodoView.All);
  };

  const showCompletedTodos = () => {
    setTodoView(TodoView.Completed);
  };

  const showUncompletedTodos = () => {
    setTodoView(TodoView.Uncompleted);
  };

  let displayedTodos;
  if (todos) {
    switch (todoView) {
      case TodoView.All:
        displayedTodos = todos;
        break;
      case TodoView.Completed:
        displayedTodos = todos.filter((todo) => todo.completed);
        break;
      case TodoView.Uncompleted:
        displayedTodos = todos.filter((todo) => !todo.completed);
        break;
      default:
        displayedTodos = todos;
    }
  };

return (
  <div className="todo-list">
    <h1>Todo List</h1>
    <form onSubmit={handleSubmit}>
      <label>
        Title:
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
        />
      </label>
      <label>
        Description:
        <input
          type="text"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
        />
      </label>
      <button type="submit">{updatingTodoId ? 'Update Todo': 'Add Todo'}</button>
    </form>
    <button onClick={showAllTodos}>Show All Todos</button>
    <button onClick={showCompletedTodos}>Show Completed Todos</button>
    <button onClick={showUncompletedTodos}>Show Uncompleted Todos</button>
    <ul>
      {Array.isArray(displayedTodos) && displayedTodos.map((todo) => (
        <li key={todo.id}>
          <h2>{todo.title}</h2>
          <p>{todo.description}</p>
          <p>Completed: {todo.completed ? 'Yes' : 'No'}</p>
          <p>Created at: {todo.created_at}</p>
          <p>Updated at: {todo.updated_at}</p>
          <button className="update-button" onClick={() => handleUpdate(todo)}>Update</button>
          <button className="delete-button" onClick={() => handleDelete(todo.id)}>Delete</button>
          <label>
            Completed:
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggleCompleted(todo.id, todo.completed)}
            />
          </label>
        </li>
      ))}
    </ul>
  </div>
);
};

export default TodoList;
```

## 最後に
コードで誤っている点がありましたらご指摘のほどお待ちしております！