Go(Echo)でTODO APIを作っていく
技術スタック
- docker
- PostgreSQL
- Go(Echo)
- GORM
- React, Typescript
要件
バックエンド開発に注力する
- 認証
- CRUD
画面設計
オブジェクトの洗い出し
- Todo
- タイトル
- 作成者
- タグ
- 作成日
- 最終更新日
- User
- id
- password
- (username)
- Tag
- 名前
オブジェクトに対してのユーザータスクの洗い出し
- Todoの作成、編集、削除
- 新規作成
- 編集
- 削除
- タグの追加、削除
- User管理
- サインアップ
- サインイン
- サインアウト
- (ロール管理)
必要な画面の洗い出し
サインイン(サインアップ)
- idとpasswordを入力する
- submitボタン
Todo一覧、作成、編集、削除
- 新規入力フォーム
- 編集フォーム
- 保存、削除、編集ボタン
- タグ追加 / 削除
画面遷移図
ER図(データ設計)
システムカラムは省略
ツールのインストール
私は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
-
Goプロジェクトの作成
-
ディレクトリの作成
mkdir go-todo-api
-
Goプロジェクトの作成
go mod init go-todo-api
LocalでPostgreSQLを起動する
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:
コンテナを立ち上げる
docker compose up -d
-
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 |
環境変数
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
modelsの定義
mkdir models
touch user.go
touch todo.go
ユーザーストラクトの定義
models/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
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"`
}
データベースを起動
準備
mkdir db
touch db.go
go get -u gorm.io/gorm
go get github.com/joho/godotenv
go get gorm.io/driver/postgres
起動、終了関数の定義
db/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)
}
}
マイグレーション
- 前セクション同様に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{})
}
ポインタについての理解が追いついていないので、別記事にて投稿したい
テーブルを作成する
go run migrate/migrate.go
- pgAdmin4を開く
- Add New Server
- General>Name : DB(なんでも良い)
- Connection>Host name/address : localhost(.envのPOSTGRES_HOSTで定義した値)
- Connection>Port : .envのPortで定義した値
- Save
- Servers>DB>Databses>admin>Schemas>Tablesにtodos,usersがあることを確認する
modelsで定義したカラムが入っている
ユーザー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) error
とSignUp(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
上記入力後、Send
を行い、responseが返ってきた
- login
- status 200, cookiesに値が入っている
- status 200, cookiesに値が入っている
pgAdmin4でユーザー確認
Servers>DB>Databses>admin>Schemas>Tables>users→View/Edit Data→All Rows
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
}
TodoUsecaseの実装
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
}
echo-jwtのインストール
go get github.com/labstack/echo-jwt/v4
TODOのエンドポイント追加
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
}
todo dependency injection
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"))
}
todo 動作確認
サーバーを起動し、postmanで確認する
GO_ENV=dev go run main.go
getAllTodos
jwtの設定をせずにGET localhost:8080/todos
を行う。
認証ができていないため下記のエラーが表示される
tokenを付与するためにユーザーAPIで作成したユーザーでloginを行う。
その後にtokenが付与されているかを確認する
)
再度/todosを実行し、statusが200、空の配列が入っていることを確認
CreateTodo
↑ではtodoがないため、post /todos
でデータを登録する
GetTodoById
UpdateTodo
DeleteTodo
todoを追加して、消したいIDをpathに指定して実行。
get /todos
で消えていることを確認
validationの実装
- プロジェクト直下にフォルダ作成
mkdir validator
- ファイル作成
touch todo_validator.go
touch user_validator.go
- packageのinstall
go get github.com/go-ozzo/ozzo-validation
- 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"),
),
)
}
todo usecaseにvalidationを適用
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
}
user usecaseにvalidationを適用
todo usecaseと同様に行っていく
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
}
作成したValidationを注入
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"))
}
動作確認
定義したメッセージが出ていることを確認
プロジェクトにmiddleware(CORSとCSRF)を追加
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
}
CSRFトークンを取得するメソッドを追加
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,
+ })
}
エンドポイントを追加
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
}
動作確認
-
/login
を叩いた時、loginができなくなる。 -
作成した
/csrf
を叩き、tokenをコピー
-
Headers
にx-CSRF-TOKEN: {コピーしたtoken}
を付与し再度/login
-
router.go
のCookieSameSite:http.SameSiteNoneMode ,
のコメントアウト解除しておく