11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-05-25

初めに

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

構成

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

  • 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

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

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

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

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

import TodoList from "./components/TodoList";

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

▼ client/src/components/TodoList.tsx

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;

最後に

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

11
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?