はじめに
こちらの記事に引き続き、Go開発環境で食品管理APIを作っていきます。
今回は食品の追加、更新、削除APIを作ります。
検証環境および開発環境
前回記事を参照
食品の登録、更新、削除APIの作成
今回はREST APIとして作成を進めていきます。
なお、処理は前回作成した取得APIのソースに付け足していきますので、重複する部分は割愛します。
RESTについて
REpresentational State Transferの略です。
RESTの4原則
- Stateless (状態を保持せず、各リクエストやレスポンスが完結していること)
- Uniform Interface (情報の操作(取得、作成、更新、削除)を統一メソッドを使用して行うこと)
- Connectability (情報に他のリソースへのリンクを含めることができること)
- Addressability (提供する情報がURIを通して表現できること)
ルーティング
main.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 |
コントローラ
食品の登録、更新、削除のコントローラを追加していきます。
...
// 食品登録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(":キー名")
を使用しています。
サービス
登録、更新、削除のためのビジネスロジックを追加します。
...
// 食品登録
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も含めて再取得し、整形した結果を返す
...
// 食品更新
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も含めて再取得し、整形した結果を返す
...
// 食品削除
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
は変更なしです。
...
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
...
データの登録、更新、削除ではトランザクション(データ一貫性の確保)を行うため、importを追加しています。
...
// 食品登録(食材と中間テーブルも登録)
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)の登録も必要です。
これはアソシエーションを用いることで実現しています。
アソシエーションの挙動はわかりづらい点が多いので、ログを出力しながら確認するのが良いと思います。
(もちろんアソシエーションを使わず、中間テーブルにInsertすることも可能です)
...
// 食品更新(食材と中間テーブルも更新)
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
を用いることで、既存の紐づけを削除し、新たな情報で紐づけを行うことができます。
...
// 食品削除(中間テーブルも削除)
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
{
"food_name" : "カレー(牛肉)",
"ingredients_name" : [
"じゃがいも",
"人参",
"牛肉",
"カレールー"
]
}
{
"data": {
"FoodName": "カレー(牛肉)",
"IngredientList": [
"カレールー",
"じゃがいも",
"人参",
"牛肉"
]
}
}
食品更新API
PUT http://localhost:8080/v1/food/カレー(牛肉)
{
"ingredients_name" : [
"じゃがいも",
"牛肉",
"カレールー",
"水"
]
}
{
"data": {
"FoodName": "カレー(牛肉)",
"IngredientList": [
"カレールー",
"じゃがいも",
"水",
"牛肉"
]
}
}
食品削除API
DELETE http://localhost:8080/v1/food/カレー(牛肉)
{}
まとめ
いかがだったでしょうか。
今回は自己学習もかねてアソシエーションを用いたり、部分的にエラー判定を入れてみたりしましたが、これがもっと複雑で規模も大きいものとなってきたら、こううまくはいかないかもしれません。
シンプルで要件を満たしたプログラムを書くのは難しいですね…兎にも角にも慣れが必要です。