PR

Go言語(Golang)のEchoでCSVファイルをインポートする方法

応用

こんにちは。Tomoyuki(@tomoyuki65)です。

Go言語のフレームワーク「Echo」において、CSVファイルをインポートして処理させたい場合もあると思います。

この記事では、Go言語(Golang)のEchoでCSVファイルをインポートする方法について解説します。

 

【関連記事】

Go言語(Golang)のEchoでシンプルかつ実務的なバックエンドAPI開発方法まとめ
こんにちは。Tomoyuki(@tomoyuki65)です。以前にGo言語のEchoでバックエンドAPIを開発する方法についての記事を書きましたが、あれから私自身もさらに成長し、もっとシンプルかつ実務的にAPIを開発する方法をまとめたいと思...

 

Go言語(Golang)のEchoでCSVファイルをインポートする方法

以前に「Go言語(Golang)のEchoでシンプルかつ実務的なバックエンドAPI開発方法まとめ」という記事を書きましたが、これにCSVファイルのインポート用APIを追加する方法を通して解説していきます。

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir -p src/internal/usecases/csv && touch src/internal/usecases/csv/import_csv.go
$ mkdir -p src/internal/handlers/csv && touch src/internal/handlers/csv/csv.go src/internal/handlers/csv/csv_test.go
$ mkdir -p src/internal/handlers/csv/data && touch src/internal/handlers/csv/data/test-data-ok.csv src/internal/handlers/csv/data/test-data-err.csv

 

次に作成した各種ファイルをそれぞれ以下のように記述します。

・「src/internal/usecases/csv/import_csv.go」

package csv

import (
  "bytes"
  "encoding/csv"
  "fmt"
  "io"
  "mime/multipart"
  "net/http"
  "unicode/utf8"

  utilContext "go-echo-v2/util/context"
  "go-echo-v2/util/logger"

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

// レスポンス結果の型定義
type ImportCsvData struct {
  No string `csv:"no" json:"no" validate:"required"`
  LastName string `csv:"last_name" json:"last_name" validate:"required"`
  FirstName string `csv:"first_name" json:"first_name" validate:"required"`
  Email string `csv:"email" json:"email" validate:"required"`
}

// インターフェースの定義
type CsvUsecase interface {
  Exec(c echo.Context) error
}

// 構造体の定義
type csvUsecase struct{}

// インスタンス生成用関数の定義
func NewCsvUsecase() CsvUsecase {
  return &csvUsecase{}
}

// CSVファイルがutf8か判定する関数
func checkUTF8(fh *multipart.FileHeader) (bool, error) {
  f, err := fh.Open()
  if err != nil {
    return false, err
  }
  defer f.Close()

  buf := bytes.NewBuffer(nil)
  io.Copy(buf, f)
  b := buf.Bytes()

  return utf8.Valid(b), nil
}

// メソッド定義
func (s *csvUsecase) Exec(c echo.Context) error {
  ctx := utilContext.CreateContext(c)

  // CSVファイルを取得(CSVファイルにはヘッダー行が必要)
  fh, err := c.FormFile("csv")
  if err != nil {
    msg := fmt.Sprintf("CSVファイルが指定されていません。: %v", err)
    logger.Warn(ctx, msg)
    return echo.NewHTTPError(http.StatusBadRequest, msg)
  }

  // CSVファイルのutf8チェック
  isUTF8, err := checkUTF8(fh)
  if err != nil {
    msg := fmt.Sprintf("CSVファイルの文字コードチェックに失敗しました。: %v", err)
    logger.Error(ctx, msg)
    return echo.NewHTTPError(http.StatusInternalServerError, msg)
  }
  if !isUTF8 {
  msg := "CSVファイルがutf8形式ではありません。"
    logger.Warn(ctx, msg)
    return echo.NewHTTPError(http.StatusUnprocessableEntity, msg)
  }

  // CSVファイルをオープン
  f, err := fh.Open()
  if err != nil {
    msg := fmt.Sprintf("CSVファイルのオープンに失敗しました: %v", err)
    logger.Error(ctx, msg)
    return echo.NewHTTPError(http.StatusInternalServerError, msg)
  }
  defer f.Close()

  // CSVリーダーの作成
  reader := csv.NewReader(f)

  // CSVデコーダーの作成
  decoder, err := csvutil.NewDecoder(reader)
  if err != nil {
    msg := fmt.Sprintf("CSVデコーダーの作成に失敗しました。: %v", err)
    logger.Error(ctx, msg)
    return echo.NewHTTPError(http.StatusInternalServerError, msg)
  }

  // データマッピング
  var csvData []ImportCsvData
  if err := decoder.Decode(&csvData); err != nil {
    msg := fmt.Sprintf("データマッピングに失敗しました。: %v", err)
    logger.Error(ctx, msg)
    return echo.NewHTTPError(http.StatusInternalServerError, msg)
  }

  // バリデーションチェック
  var errMsgs []string
  for i, data := range csvData {
    // CSVデータをログ出力
    logger.Info(ctx, fmt.Sprintf("No: %s, LastName: %s, FirstName: %s, Email: %s", data.No, data.LastName, data.FirstName, data.Email))

    if err := c.Validate(data); err != nil {
      msg := fmt.Sprintf("%d件目[%v]", i+1, err)
      errMsgs = append(errMsgs, msg)
    }
  }

  var errMsgText string
  if len(errMsgs) > 0 {
    for _, msg := range errMsgs {
      errMsgText += msg + " "
    }
    msg := fmt.Sprintf("バリデーションエラー: %v", errMsgText)
    logger.Warn(ctx, msg)
    return echo.NewHTTPError(http.StatusUnprocessableEntity, msg)
  }

  return c.JSON(http.StatusOK, csvData)
}

 

・「src/internal/handlers/csv/csv.go」

package csv

import (
  usecaseCsv "go-echo-v2/internal/usecases/csv"

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

// OpenAPI仕様書用の型定義
type ImportCsvResponse struct {
  No string `json:"no" example:"1"`
  LastName string `json:"last_name" example:"田中"`
  FirstName string `json:"first_name" example:"太郎"`
  Email string `json:"email" example:"t.tanaka@example.com"`
}

type BadRequestResponse struct {
  Message string `json:"message" example:"Bad Request"`
}

type UnauthorizedResponse struct {
  Message string `json:"message" example:"Unauthorized"`
}
type UnprocessableEntityResponse struct {
  Message string `json:"message" example:"Unprocessable Entity"`
}

type InternalServerErrorResponse struct {
  Message string `json:"message" example:"Internal Server Error"`
}

// @Description CSVファイルのインポート用API
// @Tags csv
// @Accept multipart/form-data
// @Param file formData file true "CSV file to upload"
// @Security Bearer
// @Success 200 {object} ImportCsvResponse
// @Failure 400 {object} BadRequestResponse
// @Failure 401 {object} UnauthorizedResponse
// @Failure 422 {object} UnprocessableEntityResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/csv/import [post]
func ImportCsv(c echo.Context) error {
  // インスタンス生成
  csvUsecase := usecaseCsv.NewCsvUsecase()

  // ユースケースの実行
  return csvUsecase.Exec(c)
}

 

・「src/internal/handlers/csv/csv_test.go」

package csv

import (
  "bytes"
  "encoding/json"
  "log/slog"
  "mime/multipart"
  "net/http"
  "net/http/httptest"
  "os"
  "path/filepath"
  "testing"

  "go-echo-v2/middleware"
  "go-echo-v2/util/validator"

  "github.com/joho/godotenv"
  "github.com/labstack/echo/v4"
  "github.com/stretchr/testify/assert"
  echoMiddleware "github.com/labstack/echo/v4/middleware"
)

type ImportCsvData struct {
  No string `csv:"no" validate:"required"`
  LastName string `csv:"last_name" validate:"required"`
  FirstName string `csv:"first_name" validate:"required"`
  Email string `csv:"email" validate:"required"`
}

func TestImportCsvOK(t *testing.T) {
  // .env ファイルの読み込み
  if err := godotenv.Load("../../../.env"); err != nil {
    slog.Error(".envファイルの読み込みに失敗しました。")
  }

  // テスト用のENV設定
  env := os.Getenv("ENV")
  os.Setenv("ENV", "testing")
  defer os.Setenv("ENV", env)

  // ミドルウェアの適用
  e := echo.New()
  e.Use(middleware.RequestMiddleware)
  e.Use(middleware.LoggerMiddleware())
  e.Use(middleware.CorsMiddleware())
  e.Use(echoMiddleware.Recover())
  // バリデーター設定
  e.Validator = validator.NewCustomValidator()
  v1 := e.Group("/api/v1")
  v1.POST("/csv/import", ImportCsv, middleware.AuthMiddleware)

  // CSVファイルのパス
  csvFilePath := "data/test-data-ok.csv"

  // リクエストボディの作成
  reqBody := &bytes.Buffer{}
  writer := multipart.NewWriter(reqBody)
  csvData, err := os.ReadF ile(csvFilePath)
  if err != nil {
    t.Fatalf("failed to read CSV file: %v", err)
  }

  part, err := writer.CreateFormFile("csv", filepath.Base(csvFilePath))
  if err != nil {
    t.Fatalf("failed to create form file: %v", err)
  }

  _, err = part.Write(csvData)
  if err != nil {
    t.Fatalf("failed to write CSV file to form file: %v", err)
  }
  writer.Close()

  // テスト用リクエストの作成
  req := httptest.NewRequest(http.MethodPost, "/api/v1/csv/import", reqBody)
  req.Header.Set("Content-Type", writer.FormDataContentType())
  token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
  bearerToken := "Bearer " + token
  req.Header.Set("Authorization", bearerToken)
  rec := httptest.NewRecorder()

  // テスト実行
  e.ServeHTTP(rec, req)

  // レスポンス結果のJSONを取得
  var resbody []map[string]interface{}
  if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
    t.Fatal(err)
  }

  // 検証
  assert.Equal(t, http.StatusOK, rec.Code)
  assert.Equal(t, 2, len(resbody))
  assert.Equal(t, "1", resbody[0]["no"])
  assert.Equal(t, "田中", resbody[0]["last_name"])
  assert.Equal(t, "太郎", resbody[0]["first_name"])
  assert.Equal(t, "t.tanaka@example.com", resbody[0]["email"])
  assert.Equal(t, "2", resbody[1]["no"])
  assert.Equal(t, "佐々木", resbody[1]["last_name"])
  assert.Equal(t, "一郎", resbody[1]["first_name"])
  assert.Equal(t, "ichirou.sasaki@example.com", resbody[1]["email"])
}

func TestImportCsvValidErr(t *testing.T) {
  // .env ファイルの読み込み
  if err := godotenv.Load("../../../.env"); err != nil {
    slog.Error(".envファイルの読み込みに失敗しました。")
  }

  // テスト用のENV設定
  env := os.Getenv("ENV")
  os.Setenv("ENV", "testing")
  defer os.Setenv("ENV", env)

  // ミドルウェアの適用
  e := echo.New()
  e.Use(middleware.RequestMiddleware)
  e.Use(middleware.LoggerMiddleware())
  e.Use(middleware.CorsMiddleware())
  e.Use(echoMiddleware.Recover())
  // バリデーター設定
  e.Validator = validator.NewCustomValidator()
  v1 := e.Group("/api/v1")
  v1.POST("/csv/import", ImportCsv, middleware.AuthMiddleware)

  // CSVファイルのパス
  csvFilePath := "data/test-data-err.csv"

  // リクエストボディの作成
  reqBody := &bytes.Buffer{}
  writer := multipart.NewWriter(reqBody)
  csvData, err := os.ReadF ile(csvFilePath)
  if err != nil {
     t.Fatalf("failed to read CSV file: %v", err)
  }

  part, err := writer.CreateFormFile("csv", filepath.Base(csvFilePath))
  if err != nil {
    t.Fatalf("failed to create form file: %v", err)
  }

  _, err = part.Write(csvData)
  if err != nil {
    t.Fatalf("failed to write CSV file to form file: %v", err)
  }
  writer.Close()

  // テスト用リクエストの作成
  req := httptest.NewRequest(http.MethodPost, "/api/v1/csv/import", reqBody)
  req.Header.Set("Content-Type", writer.FormDataContentType())
  token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
  bearerToken := "Bearer " + token
  req.Header.Set("Authorization", bearerToken)
  rec := httptest.NewRecorder()

  // テスト実行
  e.ServeHTTP(rec, req)

  // レスポンス結果のJSONを取得
  var resbody map[string]interface{}
  if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
    t.Fatal(err)
  }

  // 検証
  assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
  assert.Contains(t, resbody["message"], "バリデーションエラー")
  assert.Contains(t, resbody["message"], "1件目[Key: 'ImportCsvData.FirstName' Error:Field validation for 'FirstName' failed on the 'required' tag]")
  assert.Contains(t, resbody["message"], "2件目[Key: 'ImportCsvData.LastName' Error:Field validation for 'LastName' failed on the 'required' tagKey: 'ImportCsvData.FirstName' Error:Field validation for 'FirstName' failed on the 'required' tag]")
}

※「os.ReadF ile(csvFilePath)」のFとiの間には意図的にスペースを入れているため、実際に試す場合はスペースを削除して下さい。

 

・「src/internal/handlers/csv/data/test-data-ok.csv」

no,last_name,first_name,email
1,田中,太郎,t.tanaka@example.com
2,佐々木,一郎,ichirou.sasaki@example.com

 

・「src/internal/handlers/csv/data/test-data-err.csv」

no,last_name,first_name,email
1,田中,,t.tanaka@example.com
2,,,ichirou.sasaki@example.com

 

次にファイル「src/router/router.go」を以下のように修正します。

package router

import (
  "go-echo-v2/internal/handlers/csv"
  "go-echo-v2/internal/handlers/healthcheck"
  "go-echo-v2/internal/handlers/index"
  "go-echo-v2/internal/handlers/user"
  "go-echo-v2/middleware"

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

func SetupRouter(e *echo.Echo) {
  e.GET("/", index.Index)

  v1 := e.Group("/api/v1")
  v1.GET("/", index.Index, middleware.AuthMiddleware)
  v1.GET("/healthcheck", healthcheck.Healthcheck, middleware.ApiKeyAuthMiddleware)
  v1.POST("/user", user.CreateUser)
  v1.GET("/users", user.GetAllUsers, middleware.AuthMiddleware)
  v1.GET("/user/:uid", user.GetUserByUID, middleware.AuthMiddleware)
  v1.PUT("/user/:uid", user.UpdateUserByUID, middleware.AuthMiddleware)
  v1.DELETE("/user/:uid", user.DeleteUserByUID, middleware.AuthMiddleware)
  v1.POST("/csv/import", csv.ImportCsv, middleware.AuthMiddleware)
}

 

次に以下のコマンドを実行し、各種修正内容を反映させます。

$ docker compose exec api go mod tidy
$ docker compose exec api go fmt ./...
$ docker compose exec api staticcheck ./...
$ docker compose exec api swag i

 

次にPostmanでAPIを実行して試してみます。

まずはBearerトークンとCSVファイル「test-data-ok.csv」を設定してPOSTメソッドで「http://localhost:8080/api/v1/csv/import」を実行し、CSVファイルの内容がレスポンス結果に出力されればOKです。

 

次にCSVファイル「test-data-err.csv」を変更してPOSTメソッドで「http://localhost:8080/api/v1/csv/import」を実行し、バリデーションチェックでエラーになればOKです。

 

次に以下のコマンドを実行し、テストコードを試します。

$ docker compose exec api go test -v ./internal/handlers/...

 

テスト実行後、下図のように全てのテストがPASSすればOKです。

 

スポンサーリンク

最後に

今回はGo言語(Golang)のEchoでCSVファイルをインポートする方法について解説しました。

CSVファイルをインポートして何らかの処理をさせるAPIを作りたい場合、今回ご紹介した方法をぜひ参考にしてみて下さい。

 

この記事を書いた人
Tomoyuki

SE→ブロガーを経て、現在はWeb系エンジニアをしています!

Tomoyukiをフォローする
応用
スポンサーリンク
Tomoyukiをフォローする

コメント

タイトルとURLをコピーしました