Closed23

Go(Echo)でTODO APIを作っていく

tara is oktara is ok

技術スタック

  • docker
  • PostgreSQL
  • Go(Echo)
  • GORM
  • React, Typescript

要件

バックエンド開発に注力する

  • 認証
  • CRUD

画面設計

オブジェクトの洗い出し

  1. Todo
    • タイトル
    • 作成者
    • タグ
    • 作成日
    • 最終更新日
  2. User
    • id
    • password
    • (username)
  3. Tag
    • 名前

オブジェクトに対してのユーザータスクの洗い出し

  1. Todoの作成、編集、削除
    • 新規作成
    • 編集
    • 削除
    • タグの追加、削除
  2. User管理
    • サインアップ
    • サインイン
    • サインアウト
    • (ロール管理)

必要な画面の洗い出し

サインイン(サインアップ)

  • idとpasswordを入力する
  • submitボタン

Todo一覧、作成、編集、削除

  • 新規入力フォーム
  • 編集フォーム
  • 保存、削除、編集ボタン
  • タグ追加 / 削除

画面遷移図

ER図(データ設計)

システムカラムは省略

tara is oktara is ok

ツールのインストール

私はbrewからインストールしましたが、DMGからのインストール可能

  • postgreSQL
    • brew install postgresql
  • pgAdmin4
    • brew install --cask pgAdmin4
  • Postman
    • brew install postman
  • nodejs
  • Go
  • docker
    • brew install docker --cask
  • docker compose
    • 参考: M2のMacにDockerとDocker Composeをインストールする
    • sudo mkdir -p /usr/local/lib/docker/cli-plugins
    • sudo curl -SL https://github.com/docker/compose/releases/download/v2.22.0/docker-compose-darwin-aarch64 -o /usr/local/lib/docker/cli-plugins/docker-compose
      • v.2.22.0の部分をreleasesをみて適宜置き換える
    • sudo chmod a+x /usr/local/lib/docker/cli-plugins/docker-compose
    • docker compose version (確認)
      • Docker Compose version v2.22.0のように表示されたらok
tara is oktara is ok

Goプロジェクトの作成

  1. ディレクトリの作成
    mkdir go-todo-api

  2. Goプロジェクトの作成
    go mod init go-todo-api

tara is oktara is ok

LocalでPostgreSQLを起動する

docker-compose.ymlファイルの作成

docker-compose.yml
docker-compose.yml
version: "3.8"
services:
  dev-postgres:
    image: postgres:15.1-alpine
    ports:
      - 5434:5432
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: admin
      POSTGRES_DB: admin
    restart: always
    networks:
      - my-network
networks:
  my-network:

コンテナを立ち上げる

  1. docker compose up -d
  2. docker ps
    以下のような表示になる
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
41cb7f83655a postgres:15.1-alpine "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:5434->5432/tcp go-todo-api-dev-postgres-1
tara is oktara is ok

環境変数

SECRETはJWTを生成するためにキー(任意のパスを設定する)

.env
PORT=8080
POSTGRES_USER=admin
POSTGRES_PW=admin
POSTGRES_DB=admin
POSTGRES_PORT=5434
POSTGRES_HOST=localhost
SECRET=asdfghjkvcx
GO_ENV=dev
API_DOMAIN=localhost
FE_URL=http://localhost:3000
tara is oktara is ok

modelsの定義

  1. mkdir models
  2. touch user.go
  3. touch todo.go

ユーザーストラクトの定義

models/user.go
user.go
package models

import "time"

type User struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Email     string    `json:"email" gorm:"unique"`
	Password  string    `json:"password"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type UserResponse struct {
	ID    uint   `json:"id" gorm:"primaryKey"`
	Email string `json:"email" gorm:"unique"`
}

Todoストラクトの定義

models/todo.go
todo.go
package models

import "time"

type Todo struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Title     string    `json:"title" gorm:"not null"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	User      User      `json:"user" gorm:"foreignKey:UserId; constraint:OnDelete:CASCADE"`
	UserId    uint      `json:"user_id" gorm:"not null"`
}

type TodoResponse struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Title     string    `json:"title" gorm:"not null"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}
tara is oktara is ok

データベースを起動

準備

  1. mkdir db
  2. touch db.go
  3. go get -u gorm.io/gorm
  4. go get github.com/joho/godotenv
  5. go get gorm.io/driver/postgres

起動、終了関数の定義

参考: GORM-データベースに接続する

db/db.go
db.go
package db

import (
	"fmt"
	"log"
	"os"

	"github.com/joho/godotenv"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

func NewDB() *gorm.DB {
	err := godotenv.Load()
	if err != nil {
			log.Fatalln(err)
	}
	//DBに接続するためのURLをを作成
	//https://gorm.io/ja_JP/docs/connecting_to_the_database.html#PostgreSQL
	url := fmt.Sprintf("postgres://%s:%s@%s:%s/%s", os.Getenv("POSTGRES_USER"),
		os.Getenv("POSTGRES_PW"), os.Getenv("POSTGRES_HOST"),
		os.Getenv("POSTGRES_PORT"), os.Getenv("POSTGRES_DB"))
	db, err := gorm.Open(postgres.Open(url), &gorm.Config{}) //空を渡しデフォルトで起動する
	if err != nil {
		log.Fatalln(err)
	}
	fmt.Println("DB Connceted")
	return db
}

func CloseDB(db *gorm.DB) {
	sqlDB, _ := db.DB()
	if err := sqlDB.Close(); err != nil {
		log.Fatalln(err)
	}
}
tara is oktara is ok

マイグレーション

  • 前セクション同様にmigrateディレクトリ、ファイル作成する

定義

migrate/migrate.go
// NOTE: main関数コードを使用する
package main

import (
	"fmt"
	"go-todo-api/db"
	"go-todo-api/models"
)


func main(){
	dbConn := db.NewDB()
	defer fmt.Println("Successfully Migrated!")
	defer db.CloseDB(dbConn)
	dbConn.AutoMigrate(&models.User{}, &models.Todo{})
}

ポインタについての理解が追いついていないので、別記事にて投稿したい

テーブルを作成する

  1. go run migrate/migrate.go
  2. pgAdmin4を開く
  3. Add New Server
  4. General>Name : DB(なんでも良い)
  5. Connection>Host name/address : localhost(.envのPOSTGRES_HOSTで定義した値)
  6. Connection>Port : .envのPortで定義した値
  7. Save
  8. Servers>DB>Databses>admin>Schemas>Tablesにtodos,usersがあることを確認する

modelsで定義したカラムが入っている

All rowsの確認

tara is oktara is ok

ユーザーAPI

準備

  • go get github.com/golang-jwt/jwt/v5
  • go get github.com/labstack/echo/v4

リポジトリーインターフェース

/repository/user_repository.go
package repository

import "go-todo-api/models"

type IUserRepository interface {
	GetUserByEmail(user *models.User, email string) error
	CreateUser(user *models.User) error
}

ユースケース

  • リポジトリーインターフェースに依存
/usecase/user_usecase.go
package usecase

import (
	"go-todo-api/models"
	"go-todo-api/repository"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

type IUserUsecase interface {
	SignUp(user models.User) (models.UserResponse,error)
	Login(user models.User)(string, error) //JWTトークンを返す
}

type userUsecase struct {
	ur repository.IUserRepository
}

func NewUserUsecase(ur repository.IUserRepository) IUserUsecase{
	return &userUsecase{ur}
}

func (uu *userUsecase)SignUp(user models.User)(models.UserResponse,error){
	//パスワードをハッシュ化
	hash, err := 	bcrypt.GenerateFromPassword([]byte(user.Password),10) //第2引数は暗号の複雑さ
	if err != nil {
		return models.UserResponse{},err
	}
	newUser := models.User{Email: user.Email, Password: string(hash)}
	if err := uu.ur.CreateUser(&newUser); err != nil{
		return models.UserResponse{}, err
	}
	resUser := models.UserResponse{
		ID: newUser.ID,
		Email: newUser.Email,
	}
	return resUser,nil
}

func (uu *userUsecase) Login(user models.User)(string, error){
	//clientからくるemailがdbに存在するか確認する
	storedUser := models.User{}
	if err := uu.ur.GetUserByEmail(&storedUser,user.Email); err != nil{
		return "", err
	}
	//パスワードの一致確認
	err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(user.Password))
	if err != nil {
		return "", err
	}
	//JWTトークンの生成
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id": storedUser.ID,
		"exp": time.Now().Add(time.Hour * 12).Unix(), //有効期限
	})
	tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))
	if err != nil{
		return "",err
	}
	return tokenString, nil
}

リポジトリーの詳細実装

/repository/user_repository.go
type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) IUserRepository{
	return &userRepository{db}
}

func (ur *userRepository) GetUserByEmail(user *models.User, email string)error{
	if err := ur.db.Where("email=?",email).First(user).Error; err != nil{
		return err
	}
	return nil
}

func (ur *userRepository) CreateUser(user *models.User)error{
	if err := ur.db.Create(user).Error; err != nil {
		return err
	}
	return nil
}

CreateUser(user *model.User) errorSignUp(user model.User) (model.UserResponse, error)の違いについて

echo.Contextについて

  • CreateUser
    • 新しいユーザー情報をデータベースに挿入する操作を担当する。
    • このメソッドがユーザー情報をポインタ型として受け取る理由は、ユーザー情報をデータベースに挿入し、その変更を呼び出し元に反映するため。
    • ポインタ型を使用することで、関数内でユーザー情報を変更し、呼び出し元に対して変更内容を反映することが出来る
  • SignUp
    • 新しいユーザーアカウントを作成し、その情報をデータベースに挿入する前段階の操作を担当する。
    • このメソッドがユーザー情報を値型として受け取る理由は、新しいユーザーアカウントを作成する段階で、ユーザー情報を変更する必要があるため。
    • ユーザー情報を値型として受け取り、それを変更し、ハッシュ化したり新しいユーザー情報を生成する。

CreateUser メソッドはデータベースにユーザー情報を挿入し、その変更を反映するためにポインタ型を使用し、SignUp メソッドは新しいユーザー情報を生成し、ユーザーアカウントの作成段階でユーザー情報を変更するために値型を使用します。

コントローラ

controller/todo.go
package controller

import (
	"go-todo-api/models"
	"go-todo-api/usecase"
	"net/http"
	"os"
	"time"

	"github.com/labstack/echo/v4"
)

type IUserController interface {
	SignUp(c echo.Context) error
	Login(c echo.Context) error
	Logout(c echo.Context) error
}

type userController struct {
	uu usecase.IUserUsecase
	
}

//usecaseの依存関係をcontrollerに注入
func NewUserController(uu usecase.IUserUsecase) IUserController{
	return &userController{uu}
}

func (uc *userController) SignUp(c echo.Context) error {
	user := models.User{}
	//c.Bind() メソッドはHTTPリクエストのボディデータを受け取り、指定した構造体にデータを関連付けます。
	//HTTPリクエストから送信されたデータをプログラム内のデータ構造にコピーする作業
	if err := c.Bind(&user); err != nil{
		return c.JSON(http.StatusBadRequest, err.Error())
	}
	userRes, err := uc.uu.SignUp(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError,err.Error())
	}
	return c.JSON(http.StatusCreated,userRes)
}

func (uc *userController) Login(c echo.Context) error {
	user := models.User{}
	if err := c.Bind(&user); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}
	tokenString, err := uc.uu.Login(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err.Error())
	}
	//JWTトークンをcookieに設定する
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = tokenString
	cookie.Expires = time.Now().Add(24 * time.Hour)
	cookie.Path = "/"
	cookie.Domain = os.Getenv("API_DOMAIN")
	cookie.Secure = true
	cookie.HttpOnly = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)
	return c.NoContent(http.StatusOK)
}

func (uc *userController) Logout(c echo.Context) error {
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = ""
	cookie.Expires = time.Now()
	cookie.Path = "/"
	cookie.Domain = os.Getenv("API_DOMAIN")
	cookie.Secure = true
	cookie.HttpOnly = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)
	return c.NoContent(http.StatusOK)
}


router

エンドポイントの定義を行う

/router/router.go
package router

import (
	"go-todo-api/controller"

	"github.com/labstack/echo/v4"
)

func NewRouter(uc controller.IUserController) *echo.Echo{
	e := echo.New()
	//エンドポイントの追加
	e.POST("/signup", uc.SignUp)
	e.POST("/login", uc.Login)
	e.POST("/logout", uc.Logout)
	return e
}

エントリポイント

main.go
package main

import (
	"go-todo-api/controller"
	"go-todo-api/db"
	"go-todo-api/repository"
	"go-todo-api/router"
	"go-todo-api/usecase"
)

func main(){
	db := db.NewDB()
	userRepository := repository.NewUserRepository(db)
	userUsecase := usecase.NewUserUsecase(userRepository)
	userController := controller.NewUserController(userUsecase)
	e := router.NewRouter(userController)
	e.Logger.Fatal(e.Start(":8080"))
}

go run main.goでサーバーを起動する

次のように出力されればok

DB Connceted

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.11.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080

PostmanでAPIを叩いてみる

  • signup
    postman-signup

上記入力後、Sendを行い、responseが返ってきた

response

  • login
    • status 200, cookiesに値が入っている
      postman-login

pgAdmin4でユーザー確認

Servers>DB>Databses>admin>Schemas>Tables>users→View/Edit Data→All Rows

phAdmin4

tara is oktara is ok

TodoRepositoryの実装

todo_repository.go

package repository

import (
	"fmt"
	"go-todo-api/models"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

type ITodoRepository interface {
	GetAllTodos(todos *[]models.Todo, userId uint) error
	GetTodoById(todo *models.Todo, userId uint, todoId uint) error
	CreateTodo(todo *models.Todo) error
	UpdateTodo(todo *models.Todo, userId uint, todoId uint) error
	DeleteTodo(userId uint, todoId uint) error
}

type todoRepository struct {
	db *gorm.DB
}

func NewTodoRepository(db *gorm.DB) ITodoRepository{
	return &todoRepository{db}
}

func (tr *todoRepository) GetAllTodos(todos *[]models.Todo, userId uint) error {
	if err := tr.db.Joins("User").Where("user_id=?", userId).Order("created_at").Find(todos).Error; err != nil{
		return err
	}
	return nil
}

func (tr *todoRepository) GetTodoById(todo *models.Todo, userId uint, todoId uint) error{
	if err := tr.db.Joins("User").Where("user_id=?", userId).First(todo, todoId).Error; err != nil{
		return err
	}
	return nil
}

func (tr *todoRepository) CreateTodo(todo *models.Todo) error {
	if err := tr.db.Create(todo).Error; err != nil {
		return err
	}
	return nil
}

func (tr *todoRepository) UpdateTodo(todo * models.Todo, userId uint, todoId uint) error {
	result := tr.db.Model(todo).Clauses(clause.Returning{}).Where("id=? AND user_id=?",todoId, userId).Update("title", todo.Title)
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected < 1 {
		return fmt.Errorf("object does not exist")
	}
	return nil
}

func (tr *todoRepository) DeleteTodo(userId uint, todoId uint) error {
	result := tr.db.Where("id=? AND user_id=?", todoId, userId).Delete(&models.Todo{})
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected < 1 {
		return fmt.Errorf("object does not exist")
	}
	return nil
}

tara is oktara is ok

TodoUsecaseの実装

todo_usecase.go
todo_usecase.go

package usecase

import (
	"go-todo-api/models"
	"go-todo-api/repository"
)


type ITodoUsecase interface {
	GetAllTodos(userId uint) ([]models.TodoResponse,error)
	GetTodoById(userId uint, todoId uint) (models.TodoResponse, error)
	CreateTodo(todo models.Todo) (models.TodoResponse, error)
	UpdateTodo(todo models.Todo, userId uint, todoId uint) (models.TodoResponse, error)
	DeleteTodo(userId uint, todoId uint) error
}

type todoUsecase struct {
	tr repository.ITodoRepository
}

func NewTodoUsecase(tr repository.ITodoRepository) ITodoUsecase {
	return &todoUsecase{tr}
}

func (tu *todoUsecase) GetAllTodos(userId uint) ([]models.TodoResponse, error) {
	todos := []models.Todo{}
	if err := tu.tr.GetAllTodos(&todos, userId); err != nil {
		return nil, err
	}
	resTodos := []models.TodoResponse{}
	for _, v := range todos {
		t := models.TodoResponse{
			ID: v.ID,
			Title: v.Title,
			CreatedAt: v.CreatedAt,
			UpdatedAt: v.UpdatedAt,
		}
		resTodos = append(resTodos, t)
	}
	return resTodos, nil
}

func (tu *todoUsecase) GetTodoById(userId uint, todoId uint) (models.TodoResponse, error){
	todo := models.Todo{}
	if err := tu.tr.GetTodoById(&todo, userId, todoId); err != nil{
		return models.TodoResponse{}, err
	}
	resTodo := models.TodoResponse{
		ID: todo.ID,
		Title: todo.Title,
		CreatedAt: todo.CreatedAt,
		UpdatedAt: todo.UpdatedAt,
	}
	return resTodo, nil
}

func (tu *todoUsecase) CreateTodo(todo models.Todo) (models.TodoResponse, error){
	if err := tu.tr.CreateTodo(&todo); err != nil {
		return models.TodoResponse{}, err
	}
	resTodo := models.TodoResponse{
		ID: todo.ID,
		Title: todo.Title,
		CreatedAt: todo.CreatedAt,
		UpdatedAt: todo.UpdatedAt,
	}
	return resTodo, nil
}

func (tu *todoUsecase) UpdateTodo(todo models.Todo, userId uint,  todoId uint) (models.TodoResponse, error) {
	if err := tu.tr.UpdateTodo(&todo, userId, todoId); err != nil {
		return models.TodoResponse{}, err
	}
	resTodo := models.TodoResponse{
		ID: todo.ID,
		Title: todo.Title,
		CreatedAt: todo.CreatedAt,
		UpdatedAt: todo.UpdatedAt,
	}
	return resTodo, nil
}


func (tu *todoUsecase) DeleteTodo(userId uint, todoId uint) error {
	if err := tu.tr.DeleteTodo(userId, todoId); err != nil{
		return err
	}
	return nil
}

tara is oktara is ok

TODOのエンドポイント追加

router.go
router.go

package router

import (
	"go-todo-api/controller"
	"os"

+	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/labstack/echo/v4"
)

func NewRouter(uc controller.IUserController, tc controller.ITodoController) *echo.Echo{
	e := echo.New()
	//エンドポイントの追加
	e.POST("/signup", uc.SignUp)
	e.POST("/login", uc.Login)
	e.POST("/logout", uc.Logout)
+	t := e.Group("/todos")
+	t.Use(echojwt.WithConfig(echojwt.Config{
+		SigningKey: []byte(os.Getenv("SECRET")),
+		TokenLookup: "cookie:token",
+	}))
+	t.GET("", tc.GetAllTodos)
+	t.GET("/:todoId", tc.GetTodoById)
+	t.POST("", tc.CreateTodo)
+	t.PUT("/:todoId", tc.UpdateTodo)
+	t.DELETE("/:todoId", tc.DeleteTodo)
	return e
}

tara is oktara is ok

todo dependency injection

main.go
main.go

package main

import (
	"go-todo-api/controller"
	"go-todo-api/db"
	"go-todo-api/repository"
	"go-todo-api/router"
	"go-todo-api/usecase"
)

func main(){
	db := db.NewDB()
	userRepository := repository.NewUserRepository(db)
+	todoRepository := repository.NewTodoRepository(db)
	userUsecase := usecase.NewUserUsecase(userRepository)
+	todoUsecase := usecase.NewTodoUsecase(todoRepository)
	userController := controller.NewUserController(userUsecase)
+	todoController := controller.NewTodoController(todoUsecase)
-	e := router.NewRouter(userController)
+	e := router.NewRouter(userController, todoController)
	e.Logger.Fatal(e.Start(":8080"))
}

tara is oktara is ok

todo 動作確認

サーバーを起動し、postmanで確認する

GO_ENV=dev go run main.go

getAllTodos

jwtの設定をせずにGET localhost:8080/todosを行う。
認証ができていないため下記のエラーが表示される

get todos without jwt token

tokenを付与するためにユーザーAPIで作成したユーザーでloginを行う。
その後にtokenが付与されているかを確認する

login
)

再度/todosを実行し、statusが200、空の配列が入っていることを確認
status ok

CreateTodo

↑ではtodoがないため、post /todosでデータを登録する

create todo

GetTodoById

get todo by id

UpdateTodo

UpdateTodo

DeleteTodo

todoを追加して、消したいIDをpathに指定して実行。

delete todo

get /todosで消えていることを確認

tara is oktara is ok

validationの実装

  1. プロジェクト直下にフォルダ作成
    mkdir validator
  2. ファイル作成
    touch todo_validator.go
    touch user_validator.go
  3. packageのinstall
    go get github.com/go-ozzo/ozzo-validation
  4. validationの実装
todo_validator.go

package validator

import (
	"go-todo-api/models"

	validation "github.com/go-ozzo/ozzo-validation"
)




type ITodoValidator interface {
	TodoValidate(todo models.Todo) error
}

type todoValidator struct {}

func NewTodoValidator() ITodoValidator {
	return &todoValidator{}
}


func (tv *todoValidator) TodoValidate(todo models.Todo) error {
	return validation.ValidateStruct(&todo, validation.Field(
		&todo.Title,
		validation.Required.Error("title is required"),
		validation.RuneLength(1,10).Error("limit 10 charactor"),
	))
}

user_validator.go

package validator

import (
	"go-todo-api/models"

	validation "github.com/go-ozzo/ozzo-validation"
	"github.com/go-ozzo/ozzo-validation/is"
)




type IUserValidator interface {
	UserValidate(user models.User) error
}

type userValidator struct {}

func NewUserValidator() IUserValidator {
	return &userValidator{}
}


func (uv *userValidator) UserValidate(user models.User) error {
	return validation.ValidateStruct(&user,
		validation.Field(
			&user.Email,
			validation.Required.Error("email is required"),
			validation.RuneLength(1,30).Error("limit 30 charactor"),
			is.Email.Error("is not valid format"),
		),validation.Field(
			&user.Password,
			validation.Required.Error("password is required"),
			validation.RuneLength(10,30).Error("min 10 max 30 charactor"),
		),
	)
}

tara is oktara is ok

todo usecaseにvalidationを適用

todo_usecase.go
todo_usecase.go

package usecase

import (
	"go-todo-api/models"
	"go-todo-api/repository"
+	"go-todo-api/validator"
)


type ITodoUsecase interface {
	GetAllTodos(userId uint) ([]models.TodoResponse,error)
	GetTodoById(userId uint, todoId uint) (models.TodoResponse, error)
	CreateTodo(todo models.Todo) (models.TodoResponse, error)
	UpdateTodo(todo models.Todo, userId uint, todoId uint) (models.TodoResponse, error)
	DeleteTodo(userId uint, todoId uint) error
}

type todoUsecase struct {
	tr repository.ITodoRepository
+	tv validator.ITodoValidator
}

- func NewTodoUsecase(tr repository.ITodoRepository) ITodoUsecase {
-	return &todoUsecase{tr}
-}
+func NewTodoUsecase(tr repository.ITodoRepository, tv validator.ITodoValidator) ITodoUsecase {
+	return &todoUsecase{tr, tv}
+}

func (tu *todoUsecase) GetAllTodos(userId uint) ([]models.TodoResponse, error) {
	todos := []models.Todo{}
	if err := tu.tr.GetAllTodos(&todos, userId); err != nil {
		return nil, err
	}
	resTodos := []models.TodoResponse{}
	for _, v := range todos {
		t := models.TodoResponse{
			ID: v.ID,
			Title: v.Title,
			CreatedAt: v.CreatedAt,
			UpdatedAt: v.UpdatedAt,
		}
		resTodos = append(resTodos, t)
	}
	return resTodos, nil
}

func (tu *todoUsecase) GetTodoById(userId uint, todoId uint) (models.TodoResponse, error){
	todo := models.Todo{}
	if err := tu.tr.GetTodoById(&todo, userId, todoId); err != nil{
		return models.TodoResponse{}, err
	}
	resTodo := models.TodoResponse{
		ID: todo.ID,
		Title: todo.Title,
		CreatedAt: todo.CreatedAt,
		UpdatedAt: todo.UpdatedAt,
	}
	return resTodo, nil
}

func (tu *todoUsecase) CreateTodo(todo models.Todo) (models.TodoResponse, error){
+         if err := tu.tv.TodoValidate(todo); err != nil {
+		return models.TodoResponse{}, err
+	}

	if err := tu.tr.CreateTodo(&todo); err != nil {
		return models.TodoResponse{}, err
	}
	resTodo := models.TodoResponse{
		ID: todo.ID,
		Title: todo.Title,
		CreatedAt: todo.CreatedAt,
		UpdatedAt: todo.UpdatedAt,
	}
	return resTodo, nil
}

func (tu *todoUsecase) UpdateTodo(todo models.Todo, userId uint,  todoId uint) (models.TodoResponse, error) {
	if err := tu.tr.UpdateTodo(&todo, userId, todoId); err != nil {
		return models.TodoResponse{}, err
	}
	resTodo := models.TodoResponse{
		ID: todo.ID,
		Title: todo.Title,
		CreatedAt: todo.CreatedAt,
		UpdatedAt: todo.UpdatedAt,
	}
	return resTodo, nil
}


func (tu *todoUsecase) DeleteTodo(userId uint, todoId uint) error {
	if err := tu.tr.DeleteTodo(userId, todoId); err != nil{
		return err
	}
	return nil
}

tara is oktara is ok

user usecaseにvalidationを適用

todo usecaseと同様に行っていく

user_usecase.go
user_usecase.go

package usecase

import (
	"go-todo-api/models"
	"go-todo-api/repository"
+	"go-todo-api/validator"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

type IUserUsecase interface {
	//値型(models.User)を取ることで、このメソッド内で新しいユーザー情報を変更し、データベースに挿入
	SignUp(user models.User) (models.UserResponse,error)
	Login(user models.User)(string, error) //JWTトークンを返す
}

type userUsecase struct {
	ur repository.IUserRepository
+	uv validator.IUserValidator
}

+func NewUserUsecase(ur repository.IUserRepository, uv validator.IUserValidator) IUserUsecase{
+	return &userUsecase{ur, uv}
+}

func (uu *userUsecase)SignUp(user models.User)(models.UserResponse,error){
+	if err := uu.uv.UserValidate(user); err != nil {
+		return models.UserResponse{}, err
+	}
	//パスワードをハッシュ化
	hash, err := 	bcrypt.GenerateFromPassword([]byte(user.Password),10) //第2引数は暗号の複雑さ
	if err != nil {
		return models.UserResponse{},err
	}
	newUser := models.User{Email: user.Email, Password: string(hash)}
	if err := uu.ur.CreateUser(&newUser); err != nil{
		return models.UserResponse{}, err
	}
	resUser := models.UserResponse{
		ID: newUser.ID,
		Email: newUser.Email,
	}
	return resUser,nil
}

func (uu *userUsecase) Login(user models.User)(string, error){
+	if err := uu.uv.UserValidate(user); err != nil {
+		return "", err
+	}
	//clientからくるemailがdbに存在するか確認する
	storedUser := models.User{}
	if err := uu.ur.GetUserByEmail(&storedUser, user.Email); err != nil{
		return "", err
	}
	//パスワードの一致確認
	err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(user.Password))
	if err != nil {
		return "", err
	}
	//JWTトークンの生成
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id": storedUser.ID,
		"exp": time.Now().Add(time.Hour * 12).Unix(), //有効期限
	})
	tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))
	if err != nil{
		return "",err
	}
	return tokenString, nil
}

tara is oktara is ok

作成したValidationを注入

main.go
main.go

package main

import (
	"go-todo-api/controller"
	"go-todo-api/db"
	"go-todo-api/repository"
	"go-todo-api/router"
	"go-todo-api/usecase"
+	"go-todo-api/validator"
)

func main(){
	db := db.NewDB()
+	userValidator := validator.NewUserValidator()
+	todoValidator := validator.NewTodoValidator()
	userRepository := repository.NewUserRepository(db)
	todoRepository := repository.NewTodoRepository(db)
-	userUsecase := usecase.NewUserUsecase(userRepository)
-	todoUsecase := usecase.NewTodoUsecase(todoRepository)
+	userUsecase := usecase.NewUserUsecase(userRepository, userValidator)
+	todoUsecase := usecase.NewTodoUsecase(todoRepository, todoValidator)
	userController := controller.NewUserController(userUsecase)
	todoController := controller.NewTodoController(todoUsecase)
	e := router.NewRouter(userController, todoController)
	e.Logger.Fatal(e.Start(":8080"))
}

tara is oktara is ok

プロジェクトにmiddleware(CORSとCSRF)を追加

router.go
router.go

package router

import (
	"go-todo-api/controller"
+	"net/http"
	"os"

	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
)

func NewRouter(uc controller.IUserController, tc controller.ITodoController) *echo.Echo{
	e := echo.New()
+	//corsのmiddleware
+	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
+		//アクセスを許可するフロントエンドのドメイン
+		AllowOrigins: []string{"http://localhost:3000", os.Getenv("FE_URL")},
+		//許可するヘッダー一覧
+		//ヘッダー経由でCSRFトークンを受け取る
+		AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept,echo.HeaderAccessControlAllowHeaders,echo.HeaderXCSRFToken},
+		AllowMethods: []string{"GET","PUT","POST","DELETE"},
+		//cookieの送受信を可能にする
+		AllowCredentials: true,
+	}))
+	e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
+		CookiePath: "/",
+		CookieDomain: os.Getenv("API_DOMAIN"),
+		CookieHTTPOnly: true,
+		//postmanなどで動作確認をする際にfalseにする必要になる
+		//自動的にsecure modeがtrueとなるため一時的にコメントアウト
+		// CookieSameSite:http.SameSiteNoneMode ,
+		CookieSameSite: http.SameSiteDefaultMode,
+		//有効期限
+		//CookieMaxAge: 60
+	}))
	//エンドポイントの追加
	e.POST("/signup", uc.SignUp)
	e.POST("/login", uc.Login)
	e.POST("/logout", uc.Logout)
	t := e.Group("/todos")
	t.Use(echojwt.WithConfig(echojwt.Config{
		SigningKey: []byte(os.Getenv("SECRET")),
		TokenLookup: "cookie:token",
	}))
	t.GET("", tc.GetAllTodos)
	t.GET("/:todoId", tc.GetTodoById)
	t.POST("", tc.CreateTodo)
	t.PUT("/:todoId", tc.UpdateTodo)
	t.DELETE("/:todoId", tc.DeleteTodo)
	return e
}

tara is oktara is ok

CSRFトークンを取得するメソッドを追加

user_controller.go
user_controller.go

package controller

import (
	"go-todo-api/models"
	"go-todo-api/usecase"
	"net/http"
	"os"
	"time"

	"github.com/labstack/echo/v4"
)

type IUserController interface {
	SignUp(c echo.Context) error
	Login(c echo.Context) error
	Logout(c echo.Context) error
+	CsrfToken(c echo.Context) error
}

type userController struct {
	uu usecase.IUserUsecase
	
}

//usecaseの依存関係をcontrollerに注入
func NewUserController(uu usecase.IUserUsecase) IUserController{
	return &userController{uu}
}

func (uc *userController) SignUp(c echo.Context) error {
	user := models.User{}
	//c.Bind() メソッドはHTTPリクエストのボディデータを受け取り、指定した構造体にデータを関連付けます。
	//HTTPリクエストから送信されたデータをプログラム内のデータ構造にコピーする作業
	if err := c.Bind(&user); err != nil{
		return c.JSON(http.StatusBadRequest, err.Error())
	}
	userRes, err := uc.uu.SignUp(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError,err.Error())
	}
	return c.JSON(http.StatusCreated,userRes)
}

func (uc *userController) Login(c echo.Context) error {
	user := models.User{}
	if err := c.Bind(&user); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}
	tokenString, err := uc.uu.Login(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err.Error())
	}
	//JWTトークンをcookieに設定する
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = tokenString
	cookie.Expires = time.Now().Add(24 * time.Hour)
	cookie.Path = "/"
	cookie.Domain = os.Getenv("API_DOMAIN")
	cookie.Secure = true
	cookie.HttpOnly = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)
	return c.NoContent(http.StatusOK)
}

func (uc *userController) Logout(c echo.Context) error {
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = ""
	cookie.Expires = time.Now()
	cookie.Path = "/"
	cookie.Domain = os.Getenv("API_DOMAIN")
	cookie.Secure = true
	cookie.HttpOnly = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)
	return c.NoContent(http.StatusOK)
}

+func (uc *userController) CsrfToken(c echo.Context) error {
+	token := c.Get("csrf").(string)
+	return c.JSON(http.StatusOK, echo.Map{
+		"csrf_token": token,
+	})
}

tara is oktara is ok

エンドポイントを追加

router.go
router.go

package router

import (
	"go-todo-api/controller"
	"net/http"
	"os"

	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func NewRouter(uc controller.IUserController, tc controller.ITodoController) *echo.Echo{
	e := echo.New()
	//corsのmiddleware
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		//アクセスを許可するフロントエンドのドメイン
		AllowOrigins: []string{"http://localhost:3000", os.Getenv("FE_URL")},
		//許可するヘッダー一覧
		//ヘッダー経由でCSRFトークンを受け取る
		AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept,echo.HeaderAccessControlAllowHeaders,echo.HeaderXCSRFToken},
		AllowMethods: []string{"GET","PUT","POST","DELETE"},
		//cookieの送受信を可能にする
		AllowCredentials: true,
	}))
	e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
		CookiePath: "/",
		CookieDomain: os.Getenv("API_DOMAIN"),
		CookieHTTPOnly: true,
		//postmanなどで動作確認をする際にfalseにする必要になる
		//自動的にsecure modeがtrueとなるため一時的にコメントアウト
		// CookieSameSite:http.SameSiteNoneMode ,
		CookieSameSite: http.SameSiteDefaultMode,
		//有効期限
		//CookieMaxAge: 60
	}))
	//エンドポイントの追加
	e.POST("/signup", uc.SignUp)
	e.POST("/login", uc.Login)
	e.POST("/logout", uc.Logout)
+	e.GET("/csrf", uc.CsrfToken)
	t := e.Group("/todos")
	t.Use(echojwt.WithConfig(echojwt.Config{
		SigningKey: []byte(os.Getenv("SECRET")),
		TokenLookup: "cookie:token",
	}))
	t.GET("", tc.GetAllTodos)
	t.GET("/:todoId", tc.GetTodoById)
	t.POST("", tc.CreateTodo)
	t.PUT("/:todoId", tc.UpdateTodo)
	t.DELETE("/:todoId", tc.DeleteTodo)
	return e
}

動作確認

  1. /loginを叩いた時、loginができなくなる。

  2. 作成した/csrfを叩き、tokenをコピー

get token

  1. Headersx-CSRF-TOKEN: {コピーしたtoken}を付与し再度/login

login with cookie

  1. router.goCookieSameSite:http.SameSiteNoneMode ,のコメントアウト解除しておく
このスクラップは2023/12/05にクローズされました