Go+Gin+GORM開発環境で食品管理APIを作る～データ登録・更新・削除編～

# はじめに

[こちらの記事](https://qiita.com/sedori/items/f3cd7c41b97f9202f842)に引き続き、Go開発環境で食品管理APIを作っていきます。

今回は食品の追加、更新、削除APIを作ります。

# 検証環境および開発環境

[前回記事](https://qiita.com/sedori/items/f3cd7c41b97f9202f842)を参照

# 食品の登録、更新、削除APIの作成

今回はREST APIとして作成を進めていきます。

なお、処理は前回作成した取得APIのソースに付け足していきますので、重複する部分は割愛します。

### RESTについて

**RE**presentational **S**tate **T**ransferの略です。

https://ja.wikipedia.org/wiki/Representational_State_Transfer

:::note info
RESTの4原則

- Stateless (状態を保持せず、各リクエストやレスポンスが完結していること)
- Uniform Interface (情報の操作（取得、作成、更新、削除）を統一メソッドを使用して行うこと)
- Connectability (情報に他のリソースへのリンクを含めることができること)
- Addressability (提供する情報がURIを通して表現できること)

:::

### ルーティング

`main.go`は変更なしです。

```golang:routes/api_route.go
...
        food := v1.Group("/food")
        {
            // 食品全件取得API
            food.GET("/", controllers.GetFoods)

            // 食品取得API
            food.GET("/:foodname", controllers.GetOneFood)

            // 食品登録API
            food.POST("/", controllers.RegisterForFood)
            // 食品更新API
            food.PUT("/:foodname", controllers.UpdateFood)
            // 食品削除API
            food.DELETE("/:foodname", controllers.DeleteFood)

        }
...
```

新たに追加したエンドポイントは次のとおりです。

| API | メソッド | エンドポイント |
| --- | --- | --- |
| 登録 | POST | /v1/food/ |
| 更新 | PUT | /v1/food/:foodname |
| 削除 | DELETE | /v1/food/:foodname |

### コントローラ

食品の登録、更新、削除のコントローラを追加していきます。

```golang:controller/cooking_controller.go

...

// 食品登録APIのリクエスト
type RegisterForFoodRequest struct {
    Food string `json:"food_name" binding:"required"`
    Ingredients []string `json:"ingredients_name" binding:"required,min=1,dive,gte=1"`
}

// 食品更新APIのリクエスト
type UpdateFoodRequest struct {
    Ingredients []string `json:"ingredients_name"`
}

...


// 食品登録
func RegisterForFood(c *gin.Context) {

    // リクエストの解析
    var json RegisterForFoodRequest
    if err := c.ShouldBindJSON(&json); err != nil {
        MakeErrorJson(c, err)
        return
    }

    // データ登録
    data,err := services.RegisterForFood(json.Food, json.Ingredients);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data" : data,
    })
}

// 食品更新
func UpdateFood(c *gin.Context) {
    food_name := c.Param("foodname")

    // リクエストの解析
    var json UpdateFoodRequest
    if err := c.ShouldBindJSON(&json); err != nil {
        MakeErrorJson(c, err)
        return
    }

    // データ更新
    data,err := services.UpdateFood(food_name, json.Ingredients);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data" : data,
    })
}

// 食品削除
func DeleteFood(c *gin.Context) {
    food_name := c.Param("foodname")

    // データ削除
    err := services.DeleteFood(food_name);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }
    c.JSON(http.StatusOK, gin.H{})
}
```

登録と更新のJSONリクエストをバインド、バリデーションするための構造体を追加します。

食材は食品に対して1つ以上存在し、名前は1文字以上であることを検証しています。
配列の要素を検証する場合は`dive`の後ろに書きます

更新と削除の処理はエンドポイントから食品名を取得するため、`c.Param(":キー名")`を使用しています。


### サービス

登録、更新、削除のためのビジネスロジックを追加します。

```golang:services/cooking_service.go

...

// 食品登録
func RegisterForFood(food_name string, ingredients []string) (food Food, err error) {

    // 存在確認
    data := models.Food{}
    data, err = models.GetOneFood(food_name, false);
    if data.Id != 0 {
        err = fmt.Errorf("その食品は既に登録されています。")
        return
    }
    // レコード0件以外はエラーとする
    if !errors.Is(err, gorm.ErrRecordNotFound) {
        return
    }

    // 食品、食材の登録
    if err = models.RegisterForFood(food_name, ingredients); err != nil {
        return
    }
    // 登録結果を再取得
    food, err = GetOneFood(food_name, true)
    return food, err
}

...

```

登録処理で行っているのは次のとおりです。
- `models.GetOneFood(food_name, false);`で登録済チェックを行う。第二引数をfalseとすることでPreloadを省略しています
- エラーコードを判別し、特定のエラー(レコードなし)以外はエラーとする　※レコードなしは正しい挙動のため無視する
- 食品と食材を登録する
- 登録した情報でPreloadも含めて再取得し、整形した結果を返す

```golang:services/cooking_service.go

...

// 食品更新
func UpdateFood(food_name string, ingredients []string) (food Food, err error) {

    data := models.Food{}
    data, err = models.GetOneFood(food_name, false);
    if err != nil {
        // 未登録の場合はエラー制御する
        if errors.Is(err, gorm.ErrRecordNotFound) {
            err = fmt.Errorf("その食品は登録されていません。")
            return
        }
        return
    }
    // 食品、食材の更新
    if err = models.UpdateFood(data, ingredients); err != nil {
        return
    }
    // 更新結果を再取得
    food, err = GetOneFood(food_name, true)

    return food, err
}

...

```

更新処理で行っているのは次のとおりです。
- `models.GetOneFood(food_name, false);`で登録済チェックを行う。こちらは更新処理なのでエラーがあれば処理を終了する
- 食品と食材を更新する
- 更新した情報でPreloadも含めて再取得し、整形した結果を返す


```golang:services/cooking_service.go

...

// 食品削除
func DeleteFood(food_name string) (err error) {

    food, err := models.GetOneFood(food_name, false)
    if err != nil {
        return
    }

    // 食品と中間テーブルの削除
    if err = models.DeleteFood(food); err != nil {
        return
    }
    return
}

...

```

削除処理で行っているのは次のとおりです。
- `models.GetOneFood(food_name, false);`で登録済チェックを行う。DeleteFood内でも実施可能ですが、他と処理を揃えました（DBアクセスは増える）
- 食品と中間テーブルを削除する。食材は削除しない（食材は他の食品で使用している可能性があるため）。

### モデル

`models/database.go`は変更なしです。

```golang:models/cooking_model.go

...

import (
    "gorm.io/gorm"
    "gorm.io/gorm/clause"
)

...

```

データの登録、更新、削除ではトランザクション(データ一貫性の確保)を行うため、importを追加しています。


```golang:models/cooking_model.go

...

// 食品登録（食材と中間テーブルも登録）
func RegisterForFood(food_name string, ingredients []string) (err error) {

    food := Food{FoodName: food_name}

    // トランザクション処理
    err = Db.Transaction(func(tx *gorm.DB) error {
        // 食品登録
        if err = tx.Debug().Create(&food).Error; err != nil {
            return err
        }
        // 食材登録
        db_ingredients := []Ingredient{}

        if db_ingredients, err = UpsertIngredients(tx, ingredients); err != nil {
            return err
        }

        // 中間テーブルの登録
        tx.Debug().Model(&food).Association("Ingredients").Append(db_ingredients)

        return nil
    })
    return
}

...

// 食材の登録（既に存在していれば上書き）
func UpsertIngredients(tx *gorm.DB, ingredients []string) (db_ingredients []Ingredient, err error) {
    for _, ingredient := range ingredients {
        // 食材が未登録であれば追加
        db_ingredient := Ingredient{}
        tx.Debug().Where("ingredient_name = ?", ingredient).First(&db_ingredient)

        db_ingredient.IngredientName = ingredient

        // 食材テーブルにUpsert
        if err = tx.Debug().Save(&db_ingredient).Error; err != nil {
            return
        }
        db_ingredients = append(db_ingredients, db_ingredient)
    }
    return
}

...

```

登録の処理はトランザクション(`Db.Transaction(func(tx *gorm.DB) error { /*処理*/ })`)内で行います。

また、トランザクション内でのDB操作は、`Db`ではなく`tx`を用いて行います。

データ登録は`Create(&food)`で行います。引数に渡している変数(構造体)は、テーブルと同じ構成をしたデータを指定します。

食材は食品に対して複数存在しているため、`UpsertIngredients`で繰り返し処理を行っています。
`Save`を使うことで、渡したデータが存在していれば上書き(Update)を、存在していなければ登録(Insert)をするようにしています。

食品と食材を登録するだけではそれぞれが紐づかないため、中間テーブル(ingredients_foods)の登録も必要です。
これはアソシエーションを用いることで実現しています。

https://gorm.io/ja_JP/docs/associations.html

:::note info
アソシエーションの挙動はわかりづらい点が多いので、ログを出力しながら確認するのが良いと思います。
（もちろんアソシエーションを使わず、中間テーブルにInsertすることも可能です）
:::


```golang:models/cooking_model.go
...

// 食品更新（食材と中間テーブルも更新）
func UpdateFood(food Food, ingredients []string) (err error) {
    // トランザクション処理
    err = Db.Transaction(func(tx *gorm.DB) error {
        // // 食品テーブルを更新する場合
        // if err = tx.Debug().Save(&food).Error; err != nil {
        //     return err
        // }

        // 食材登録
        db_ingredients, err := UpsertIngredients(tx, ingredients);
        if err != nil {
            return err
        }
        // 中間テーブルの登録
        tx.Debug().Model(&food).Association("Ingredients").Replace(db_ingredients)
        return nil;
    })
    return
}

...
```
更新処理も登録処理と同様、トランザクション内で処理を行います。
基本的な流れは登録処理と同じで、`Save`を用いて食品情報を更新し、食材情報を更新または登録します。

中間テーブルはアソシエーションの`Replace`を用いることで、既存の紐づけを削除し、新たな情報で紐づけを行うことができます。


```golang:models/cooking_model.go
...

// 食品削除（中間テーブルも削除）
func DeleteFood(food Food) (err error) {

    Db.Transaction(func(tx *gorm.DB) error {
        // 中間テーブルと食品を削除
        if err := tx.Debug().Select(clause.Associations).Delete(&food).Error; err != nil {
            return err
        }
        return nil
    })
    return
}

...
```

削除処理でもアソシエーションを利用しています。
この方法では`Delete`で渡した情報を外部キーとしている中間テーブルのレコードも同時に消すことができます。


## 動作確認

実装が完了したら、動作確認をしてみましょう。

#### 食品登録API

`POST http://localhost:8080/v1/food`

```json:jsonリクエスト
{
    "food_name" : "カレー(牛肉)",
    "ingredients_name" : [
        "じゃがいも",
        "人参",
        "牛肉",
        "カレールー"
    ]
}
```

```json:レスポンス
{
    "data": {
        "FoodName": "カレー(牛肉)",
        "IngredientList": [
            "カレールー",
            "じゃがいも",
            "人参",
            "牛肉"
        ]
    }
}
```

#### 食品更新API

`PUT http://localhost:8080/v1/food/カレー(牛肉)`

```json:jsonリクエスト
{
    "ingredients_name" : [
        "じゃがいも",
        "牛肉",
        "カレールー",
        "水"
    ]
}
```

```json:レスポンス
{
    "data": {
        "FoodName": "カレー(牛肉)",
        "IngredientList": [
            "カレールー",
            "じゃがいも",
            "水",
            "牛肉"
        ]
    }
}
```

#### 食品削除API

`DELETE http://localhost:8080/v1/food/カレー(牛肉)`

```json:レスポンス
{}
```

# まとめ

いかがだったでしょうか。

今回は自己学習もかねてアソシエーションを用いたり、部分的にエラー判定を入れてみたりしましたが、これがもっと複雑で規模も大きいものとなってきたら、こううまくはいかないかもしれません。

シンプルで要件を満たしたプログラムを書くのは難しいですね…兎にも角にも慣れが必要です。