PR

Go言語(Golang)のEchoでバックエンドAPIの開発方法まとめ

応用

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

Go言語(Golang)にも様々なフレームワークがありますが、シンプルでよく利用されているGinよりも機能性が高くて人気なものとして「Echo」があります。

Echoはフルスタックフレームワークではありませんが、ちゃんと作り込むならGinよりもEchoの方が作りやすいのかなと思い、一度試して見ることにしました。

そこでこの記事では、EchoでバックエンドAPIを開発する方法についてまとめます。

 

Go言語(Golang)のEchoでバックエンドAPIの開発方法まとめ

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

$ mkdir go_echo && cd go_echo
$ mkdir api && cd api
$ touch .env
$ touch compose.yml
$ mkdir -p docker/local/go && touch docker/local/go/Dockerfile
$ mkdir src && touch src/main.go

 

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

ENV=local
MYSQL_ROOT_PASSWORD=root-password
MYSQL_USER=echo-user
MYSQL_PASSWORD=echo-password
TZ=Asia/Tokyo

 

services:
  api:
    container_name: go-echo-api
    build:
      context: .
      dockerfile: ./docker/local/go/Dockerfile
    env_file:
      - ./.env
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    tty: true
    stdin_open: true
    ports:
      - "80:8080"

 

FROM golang:1.22.3-alpine

WORKDIR /go/src

# インストール可能なパッケージ一覧の更新
RUN apk update && \
    apk upgrade && \
    # パッケージのインストール(--no-cacheでキャッシュ削除)
    apk add --no-cache \
            git

COPY ./src .

# モジュールの依存関係をインストール
RUN if [ -f ./go.mod ]; then \
      go install; \
    fi

# air をインストール
RUN go install github.com/air-verse/air@latest

EXPOSE 8080

※go.modファイルが存在していたら「go install」を実行し、go.modで管理しているライブラリをインストールしています。また、ホットリロード用にairをインストールして使います。

 

package main

import (
    "net/http"
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World !!")
    })
    e.Logger.Fatal(e.Start(":8080"))
}

 

次に以下のコマンドを実行し、コンテナのビルドから各種初期化処理、そして最後にコンテナ起動を行います。

$ docker compose build --no-cache
$ docker compose run --rm api go mod init api
$ docker compose run --rm api air init
$ docker compose run --rm api go mod tidy
$ docker compose up -d

※「go mod init」で初期化するとgo.modファイルが作成されます。このgo.modでライブラリや依存関係などの管理しますが、「go mod tidy」でコード内で使われているライブラリの追加や、使われていないライブラリの削除をしてライブラリの管理をしています。また「air init」もホットリロード用のairを使うための初期化ファイルを作成します。

 

次にブラウザで「http://localhost」にアクセスし、下図のように表示されればOKです。

 

リポジトリパターンによるルーティング設定

次にリポジトリパターンによるルーティング設定をするため、以下のコマンドを実行してルーター、ハンドラー、サービスをそれぞれ作成します。

$ mkdir -p src/router && touch src/router/router.go
$ mkdir -p src/internal/handlers/index && touch src/internal/handlers/index/index.go
$ mkdir -p src/internal/services/hello && touch src/internal/services/hello/hello.go

※internalディレクトリは特殊なディレクトリです。今回の場合はsrcディレクトリの外にあるmain.goなどから、internalディレクトリ内にあるファイルを直接読み込めなくなります。

 

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

package hello

import (
    "context"
)

// インターフェースの定義
type HelloService interface {
    GetHello(ctx context.Context) (string, error)
    PostHello(ctx context.Context, text string) (string, error)
}

// 空の構造体を定義
type helloService struct{}

// helloServiceのポインタを返す関数
func NewHelloService() HelloService {
    return &helloService{}
}

// インターフェースの各サービスを実装
func (s *helloService) GetHello(ctx context.Context) (string, error) {

    res := "Hello, World !!"

    return res, nil
}

func (s *helloService) PostHello(ctx context.Context, text string) (string, error) {

    res := "Hello, World !!"

    if (text != "") {
        res = text
    }

    return res, nil
}

※Goでは関数名の最初の文字を大文字にすると外部ファイルから呼び出せます。外部ファイルから呼び出すことがない関数名などは最初の文字を小文字にします。

 

package index

import (
    "net/http"
    "github.com/labstack/echo/v4"
    "api/internal/services/hello"
)

// リクエストボディの構造体
type RequestBody struct {
    Text string `json:"text" form:"text"`
}

// ハンドラーの構造体
type indexHandler struct {
    helloService hello.HelloService
}

// indexHandlerのポインタを返す関数
func NewIndexHandler(e *echo.Echo) *indexHandler {
    return &indexHandler{
        helloService: hello.NewHelloService(),
    }
}

// PostIndexのレスポンスの構造体
type PostIndexResponse struct {
    Message string `json:"message"`
}

// ハンドラーを実装
func (h *indexHandler) GetIndex(c echo.Context) error {
    getHello, err := h.helloService.GetHello(c.Request().Context())
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get hello")
    }

    res := map[string]interface{}{
               "message": getHello,
           }

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

func (h *indexHandler) PostIndex(c echo.Context) error {
    var r RequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    postHello, err := h.helloService.PostHello(c.Request().Context(), r.Text)
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post hello")
    }

    res := PostIndexResponse{
        Message: postHello,
    }

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

※「api/internal/services/hello」のapiの部分はgo.modファイル作成時に指定した文字列

 

package router

import (
    "github.com/labstack/echo/v4"
    "api/internal/handlers/index"
)

func SetupRouter(e *echo.Echo) {
    indexHandler := index.NewIndexHandler(e)
    e.GET("/", indexHandler.GetIndex)

    v1 := e.Group("/api/v1")
    v1.POST("/hello", indexHandler.PostIndex)
}

 

次にmain.goを以下のように修正します。

package main

import (
    "github.com/labstack/echo/v4"
    "api/router"
)

func main() {
    e := echo.New()

    // ルーティング設定
    router.SetupRouter(e)

    e.Logger.Fatal(e.Start(":8080"))
}

 

次にPostmanで作成したAPIを実行してみますが、ここではPostmanの詳細については割愛させていただきます。

ではPostmanからGETメソッドで「http://localhost」を実行し、下図のように想定通りのメッセージを取得して正常終了すればOKです。

 

次にPOSTメソッドで「http://localhost/api/v1/hello」を実行し、下図のように想定通りのメッセージを取得して正常終了すればOKです。

 

POSTメソッドで「http://localhost/api/v1/hello」を実行する際にリクエストボディを設定しなかった場合は、下図のようにデフォルトで設定したメッセージを取得して正常終了すればOKです。

 

スポンサーリンク

リクエストボディのバリデーションチェックを追加

次にPOSTリクエスト時のパラメータに対してバリデーションチェックをできるようにします。

以下のコマンドを実行し、バリデーション用のファイルを作成します。

$ mkdir -p src/pkg/validator && touch src/pkg/validator/validator.go

 

次に作成したファイルについて、以下のように記述します。

package validator

import (
    "github.com/go-playground/validator/v10"
)

type customValidator struct {
    validator *validator.Validate
}

func (cv *customValidator) Validate(i interface{}) error {
    // 必要であれば、ここでカスタムバリデーションルールを追加
    return cv.validator.Struct(i)
}

func NewCustomValidator() *customValidator {
    return &customValidator{
        validator: validator.New(),
    }
}

※バリデーションチェックをするために「github.com/go-playground/validator/v10」を使います。

 

次にmain.goを以下のように修正します。

package main

import (
    "github.com/labstack/echo/v4"
    "api/router"
    "api/pkg/validator"
)

func main() {
    e := echo.New()

    // バリデーション設定
    e.Validator = validator.NewCustomValidator()

    // ルーティング設定
    router.SetupRouter(e)

    e.Logger.Fatal(e.Start(":8080"))
}

 

次に「src/internal/handlers/index/index.go」を以下のように修正し、リクエストボディの構造体にvalidate(必須「required」)の追加、およびPostIndexハンドラーにバリデーションチェック処理を追加します。

package index

import (
    "net/http"
    "github.com/labstack/echo/v4"
    "api/internal/services/hello"
)

// リクエストボディの構造体
type RequestBody struct {
    Text string `json:"text" form:"text" validate:"required"`
}

// ハンドラーの構造体
type indexHandler struct {
    helloService hello.HelloService
}

// indexHandlerのポインタを返す関数
func NewIndexHandler(e *echo.Echo) *indexHandler {
    return &indexHandler{
        helloService: hello.NewHelloService(),
    }
}

// PostIndexのレスポンスの構造体
type PostIndexResponse struct {
    Message string `json:"message"`
}

// ハンドラーを実装
func (h *indexHandler) GetIndex(c echo.Context) error {
    getHello, err := h.helloService.GetHello(c.Request().Context())
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get hello")
    }

    res := map[string]interface{}{
               "message": getHello,
           }

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

func (h *indexHandler) PostIndex(c echo.Context) error {
    var r RequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    postHello, err := h.helloService.PostHello(c.Request().Context(), r.Text)
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post hello")
    }

    res := PostIndexResponse{
        Message: postHello,
    }

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

 

次に以下のコマンドを実行し、go.modファイルに反映しておきます。

$ docker compose exec api go mod tidy

 

次にバリデーションチェックを確認するため、リクエストボディ未設定で「POST http://localhost/api/v1/hello」を実行し、下図のように400エラーになればOKです。

 

テストコードの追加

次に上記で作成したindexハンドラーに対してテストコードを追加するため、以下のコマンドを実行してテストコード用のファイルを作成します。

$ touch src/internal/handlers/index/index_test.go

 

次に作成したファイルについて、以下のように記述します。

package index

import (
    "testing"
    "net/http"
    "net/http/httptest"
    "strings"
    "bytes"

    "api/pkg/validator"
    "api/internal/services/hello"
    
    "github.com/labstack/echo/v4"
    "github.com/stretchr/testify/assert"
)

// テスト用のEcho設定
func echoSetup() *echo.Echo {
    e := echo.New()
    e.Validator = validator.NewCustomValidator()
    return e
}

// メッセージ「{"message":"Hello, World !!"}」を出力して正常終了すること
func TestGetIndex(t *testing.T) {
    // ハンドラーのインスタンス作成
    handler := &indexHandler{
        helloService: hello.NewHelloService(),
    }

    e := echoSetup()

    // テスト用サーバーの作成
    req := httptest.NewRequest(http.MethodGet, "/", nil)
    rec := httptest.NewRecorder()
    c := e.NewContext(req, rec)

    // ハンドラーの実行
    err := handler.GetIndex(c)
    if err != nil {
        c.Error(err)
    }

    // 実行結果の検証
    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, `{"message":"Hello, World !!"}`, strings.TrimSpace(rec.Body.String()))
}

// パラメータに指定したtextを出力して正常終了すること
func TestPostIndex(t *testing.T) {
    // ハンドラーのインスタンス作成
    handler := &indexHandler{
        helloService: hello.NewHelloService(),
    }

    e := echoSetup()

    // テスト用サーバーの作成
    body := []byte(`{"text": "テスト実行!"}`)
    req := httptest.NewRequest(http.MethodPost, "/api/v1/hello", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()
    c := e.NewContext(req, rec)

    // ハンドラーの実行
    err := handler.PostIndex(c)
    if err != nil {
        c.Error(err)
    }

    // 実行結果の検証
    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, `{"message":"テスト実行!"}`, strings.TrimSpace(rec.Body.String()))
}

// パラメータにtextを指定しない場合に400エラーになること
func TestPostIndexNotParamText(t *testing.T) {
    // ハンドラーのインスタンス作成
    handler := &indexHandler{
        helloService: hello.NewHelloService(),
    }

    e := echoSetup()

    // テスト用サーバーの作成
    req := httptest.NewRequest(http.MethodPost, "/api/v1/hello", nil)
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()
    c := e.NewContext(req, rec)

    // ハンドラーの実行
    err := handler.PostIndex(c)
    if err != nil {
        c.Error(err)
    }

    // 実行結果の検証
    assert.Error(t, err)
    assert.Equal(t, http.StatusBadRequest, rec.Code)
}

 

次に以下のコマンドを実行し、go.modファイルに反映しておきます。

$ docker compose exec api go mod tidy

 

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

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

※Dockerfileで指定したWORKDIRが「/go/src」のため、パスに「./internal/handlers/…」を指定するとhandlers直下にあるテストコードが実行できます。

 

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

 

スポンサーリンク

OpenAPIのAPI仕様書を作成

次にOpenAPIでAPI仕様書を作成しますが、「swag」というライブラリを使用するため、まずはDockerfileを以下のように修正します。

FROM golang:1.22.3-alpine

WORKDIR /go/src

# インストール可能なパッケージ一覧の更新
RUN apk update && \
    apk upgrade && \
    # パッケージのインストール(--no-cacheでキャッシュ削除)
    apk add --no-cache \
            git

COPY ./src .

# モジュールの依存関係をインストール
RUN if [ -f ./go.mod ]; then \
      go install; \
    fi

# air をインストール
RUN go install github.com/air-verse/air@latest

# API仕様書作成用のswagをインストール
RUN go install github.com/swaggo/swag/cmd/swag@latest

EXPOSE 8080

 

次に以下のコマンドを実行し、コンテナの再ビルドおよび、再起動後にmodファイルの修正を行います。

$ docker compose down
$ docker compose build --no-cache
$ docker compose up -d
$ docker compose exec api go mod tidy

 

次にAPI仕様書作成用にコメントを追記する必要があるため、「api/src/main.go」と「api/src/internal/handlers/index/index.go」をそれぞれ以下のように修正します。

package main

import (
    "github.com/labstack/echo/v4"
    "api/router"
    "api/pkg/validator"
    echoSwagger "github.com/swaggo/echo-swagger"
    _ "api/docs"
)

// @title go_echo API
// @version 1.0
// @description Go言語(Golang)のフレームワーク「Echo」によるバックエンドAPIのサンプル
// @host localhost
// @BasePath /api/v1
func main() {
    e := echo.New()

    // API仕様書の設定
    e.GET("/swagger/*", echoSwagger.WrapHandler)

    // バリデーション設定
    e.Validator = validator.NewCustomValidator()

    // ルーティング設定
    router.SetupRouter(e)

    e.Logger.Fatal(e.Start(":8080"))
}

 

package index

import (
    "net/http"
    "github.com/labstack/echo/v4"
    "api/internal/services/hello"
)

// リクエストボディの構造体
type RequestBody struct {
    Text string `json:"text" form:"text" validate:"required" example:"こんにちは!"`
}

// ハンドラーの構造体
type indexHandler struct {
    helloService hello.HelloService
}

// indexHandlerのポインタを返す関数
func NewIndexHandler(e *echo.Echo) *indexHandler {
    return &indexHandler{
        helloService: hello.NewHelloService(),
    }
}

// PostIndexのレスポンスの構造体
type PostIndexResponse struct {
    Message string `json:"message" example:"こんにちは!"`
}

// ハンドラーを実装
func (h *indexHandler) GetIndex(c echo.Context) error {
    getHello, err := h.helloService.GetHello(c.Request().Context())
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get hello")
    }

    res := map[string]interface{}{
               "message": getHello,
           }

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

// @Description パラメータのtextを出力する
// @Tags index
// @Param text body RequestBody true "入力テキスト"
// @Success 200 {object} PostIndexResponse "出力メッセージ"
// @Failure 400
// @Failure 500
// @Router /hello [post]
func (h *indexHandler) PostIndex(c echo.Context) error {
    var r RequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    postHello, err := h.helloService.PostHello(c.Request().Context(), r.Text)
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post hello")
    }

    res := PostIndexResponse{
        Message: postHello,
    }

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

※リクエストとレスポンスの構造体の項目にexampleの設定を追加しておく

 

次に以下のコマンドを実行し、API仕様書のファイルを出力します。

$ docker compose exec api swag i 

 

コマンド実行に成功すると、下図のようにdocsディレクトリと各種ファイルが作成されます。

 

次に以下のコマンドを実行し、サーバーを再起動します。

$ docker compose exec api go mod tidy
$ docker compose down
$ docker compose up -d

 

次にブラウザで「http://localhost/swagger/index.html」にアクセスし、下図のようにAPI仕様書が表示されればOKです。

 

また、テキストエディタにVSCodeを使っている場合は、拡張機能の「OpenAPI (Swagger) Editor」をインストールし、「api/src/docs/swagger.json」をアクティブ状態にして画面右上のボタンをクリックするとAPI仕様書を確認できます。

 

尚、API仕様書に出力される内容についてはコメント部分の書き方を修正すれば変更が可能ですが、使えるタグなどの詳細については「https://github.com/swaggo/swag」のドキュメントをご確認下さい。

 

API仕様書をmdファイルに変換したい場合

上記で作成した「swagger.json」をmdファイルの形式に変換すると、GitHubで管理した場合に直接確認できるようになります。

変換したい場合は、nodeのnpmパッケージ「openapi-to-md」を使うことで簡単に行うことが可能です。(事前にローカル等にnodeをインストールしてnpmを使えるようにする必要がありますが、ここでは詳しい説明は割愛させていただきます)

npmパッケージが使える場合、以下のコマンドを実行して「openapi-to-md」をインストールします。

$ npm i openapi-to-md

 

上記コマンド実行後に「api/package.json」が作成されるので、コマンド実行用のスクリプトを以下のように記述します。

{
  "scripts": {
    "openapi-to-md": "openapi-to-md"
  },
  "dependencies": {
    "openapi-to-md": "^1.0.24"
  }
}

 

次に以下のコマンドを実行し、「api/src/docs/swagger.json」から「api/src/docs/openapi.md」を作成します。
$ npm run openapi-to-md src/docs/swagger.json > src/docs/openapi.md

 

上記で作成したmdファイルをGitHubで確認すると、下図のように表示されます。

 

スポンサーリンク

コンフィグ設定の追加

次にコンフィグ設定を追加するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/config && touch src/config/config.go src/config/config.local.yml src/config/config.testing.yml

 

次に作成した各種ファイルについて、それぞれ次のように記述します。

db:
  host: "db"
  port: 3306
  database: "echo-db"
  charset: "utf8mb4"
  collation: "utf8mb4_bin"
  parseTime: true

 

db:
  host: "db"
  port: 3306
  database: "echo-db-testing"
  charset: "utf8mb4"
  collation: "utf8mb4_bin"
  parseTime: true

 

package config

import (
    "os"
    "github.com/spf13/viper"
    "github.com/fsnotify/fsnotify"
    "fmt"
)

type Config struct {
    Env string
    Db  Database `yml:db`
}

type Database struct {
    UserName  string
    Password  string
    Host      string `yml:host`
    Port      int    `yml:port`
    Database  string `yml:database`
    Charset   string `yml:charset`
    Collation string `yml:collation`
    ParseTime bool   `yml:parseTime`
    Loc       string
}

func SetupConfig(env string) (*Config, error) {

    var config *Config

    if env != "testing" {
        env = os.Getenv("ENV")
    }

    user := os.Getenv("MYSQL_USER")
    password := os.Getenv("MYSQL_PASSWORD")
    tz := os.Getenv("TZ")

    // viperの初期設定
    viper.SetConfigName("config." + env)
    viper.SetConfigType("yml")
    viper.AddConfigPath("config/")

    // configファイルの読み込み
    err := viper.ReadInConfig()
    if err != nil {
        panic(err)
    }

    // 読み込んだデータを変数cfgに設定
    err = viper.Unmarshal(&config)
    if err != nil {
        panic(err)
    }

    // configファイル更新時に再読み込み
    viper.WatchConfig()
    viper.OnConfigChange(func(e fsnotify.Event) {
        fmt.Println("Config file changed:", e.Name)
    })

    // 環境変数の値を変数cfgに設定
    config.Env = env
    config.Db.UserName = user
    config.Db.Password = password
    config.Db.Loc = tz

    return config, nil
}

※コンフィグ設定は環境に応じて設定値を切り替えられるようにするため、「github.com/spf13/viper」を使っています。パラメータが「testing」の場合は「src/config/config.testing.yml」の値を使い、それ以外は環境変数「ENV」の値と同名のファイルの値を使うよう切り替えられます。

 

次に以下のコマンドを実行し、modファイルを更新しておきます。

$ docker compose exec api go mod tidy

 

スポンサーリンク

データベース関連設定の追加

次にデータベース関連設定を追加しますが、今回の例ではデータベースに「MySQL」、ORM(オブジェクト関係マッピング)やマイグレーション用に「ent.」と「atlas」を使います。

まず以下のコマンドを実行し、データベース作成用の各種ファイルを作成します。

$ mkdir -p docker/local/db/init && touch docker/local/db/init/init.sql
$ mkdir -p docker/local/db/conf && touch docker/local/db/conf/my.conf

 

次に作成したファイルについて、それぞれ次のように記述します。

-- 存在しない場合のみ通常DBを作成
CREATE DATABASE IF NOT EXISTS `echo-db` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

-- 存在しない場合のみテストDBを作成
CREATE DATABASE IF NOT EXISTS `echo-db-testing` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

-- 存在しない場合のみユーザーを作成
CREATE USER IF NOT EXISTS 'echo-user'@'%' IDENTIFIED BY 'echo-password';

-- DBに権限付与
GRANT ALL PRIVILEGES ON `echo-db`.* TO 'echo-user'@'%';
GRANT ALL PRIVILEGES ON `echo-db-testing`.* TO 'echo-user'@'%';

-- 権限の変更反映
FLUSH PRIVILEGES;

※作成するデータベース名、ユーザー名、パスワードは、.envファイル等に設定した値に合わせてハードコーディングしています。また通常使うDBの他、テスト用のDBも作成しておきます。

 

[client]
default-character-set=utf8mb4

 

次にDockerfileとcompose.ymlをそれぞれ以下のように修正します。

FROM golang:1.22.3-alpine

WORKDIR /go/src

# インストール可能なパッケージ一覧の更新
RUN apk update && \
    apk upgrade && \
    # パッケージのインストール(--no-cacheでキャッシュ削除)
    apk add --no-cache \
            git \
            curl

COPY ./src .

# モジュールの依存関係をインストール
RUN if [ -f ./go.mod ]; then \
      go install; \
    fi

# air をインストール
RUN go install github.com/air-verse/air@latest

# API仕様書作成用のswagをインストール
RUN go install github.com/swaggo/swag/cmd/swag@latest

# マイグレーション用のatlasをインストール
RUN curl -sSf https://atlasgo.sh | sh

EXPOSE 8080

 

services:
  api:
    container_name: go-echo-api
    build:
      context: .
      dockerfile: ./docker/local/go/Dockerfile
    env_file:
      - ./.env
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    tty: true
    stdin_open: true
    ports:
      - "80:8080"
    depends_on:
      - db
  db:
    container_name: go-echo-db
    image: mysql:8.0.37
    env_file:
      - ./.env 
    ports:
      - 3306:3306
    volumes:
      - ./docker/local/db/init:/docker-entrypoint-initdb.d/
      - ./docker/local/db/conf:/etc/mysql/conf.d/
      - mysql-echo-db:/var/lib/mysql
Volumes:
  mysql-echo-db:
    driver: local

※コンテナ起動時に上記で作成したSQLが起動するようにdbのvolumesの「- ./docker/local/db/init:/docker-entrypoint-initdb.d/」で設定しています。またDBのデータはボリュームの「mysql-echo-db」に保存するようにしています。

 

次に以下のコマンドを実行し、コンテナを再起動します。

$ docker compose down
$ docker compose build --no-cache
$ docker compose up -d

 

次に以下のコマンドを実行し、データベース設定用のファイルを作成します。

$ mkdir -p src/database && touch src/database/database.go

 

次に作成したファイルについて、次のように記述します。

package database

import (
    "fmt"
    "net/url"
    "context"
    "log"

    "api/config"
    "api/ent"

    "entgo.io/ent/dialect"
    _ "github.com/go-sql-driver/mysql"
)

func SetupDatabase(env string) (*ent.Client, error) {

    // コンフィグ設定の取得
    var cfg *config.Config
    var err error

    if env == "testing" {
        cfg, err = config.SetupConfig("testing")
    } else {
        cfg, err = config.SetupConfig("")
    }

    if err != nil {
        fmt.Println("コンフィグ設定取得エラー", err)
        return nil, err
    }

    // ロケーションのエンコード
    encodedLoc := url.PathEscape(cfg.Db.Loc)

    dsn := fmt.Sprintf(
               "%s:%s@tcp(%s:%d)/%s?charset=%s&collation=%s&parseTime=%t&loc=%s",
               cfg.Db.UserName,
               cfg.Db.Password,
               cfg.Db.Host,
               cfg.Db.Port,
               cfg.Db.Database,
               cfg.Db.Charset,
               cfg.Db.Collation,
               cfg.Db.ParseTime,
               encodedLoc,
           )

    // DB接続
    var client *ent.Client
    client, err = ent.Open(dialect.MySQL, dsn)

    if err != nil {
        fmt.Println("DB接続エラー", err)
        return nil, err
    }

    // DBスキーマ作成
    if err = client.Schema.Create(context.Background()); err != nil {
        log.Fatalf("DBスキーマ作成に失敗しました。: %v", err)
    }

    return client, nil
}

func GetDsnForMigrate(env string) (string, error) {

    // コンフィグ設定の取得
    var cfg *config.Config
    var err error

    if env == "testing" {
        cfg, err = config.SetupConfig("testing")
    } else {
        cfg, err = config.SetupConfig("")
    }

    if err != nil {
        fmt.Println("コンフィグ設定取得エラー", err)
        return "", err
    }

    // DBの接続先設定
    dsn := fmt.Sprintf(
               "mysql://%s:%s@%s:%d/%s?charset=%s&collation=%s&parseTime=%t&loc=%s",
               cfg.Db.UserName,
               cfg.Db.Password,
               cfg.Db.Host,
               cfg.Db.Port,
               cfg.Db.Database,
               cfg.Db.Charset,
               cfg.Db.Collation,
               cfg.Db.ParseTime,
               cfg.Db.Loc,
           )

    return dsn, nil
}

※通常のDBに接続する場合は「SetupDatabase(“”)」、テスト用のDBに接続する場合は「SetupDatabase(“testing”)」を使う。

 

次に以下のコマンドを実行し、modファイルを更新しておきます。

$ docker compose exec api go mod tidy

 

スポンサーリンク

ent.とatlasでスキーマ定義とマイグレーションファイルを作成

次に以下のコマンドを実行し、ent.でユーザーテーブル用のスキーマ定義を作成します。

$ docker compose exec api go run -mod=mod entgo.io/ent/cmd/ent init User

 

次に作成されたファイル「api/src/ent/schema/user.go」を以下のように修正します。

package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/index"
)

type User struct {
   ent.Schema
}

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.Int64("id"),
        field.String("uid").
            NotEmpty().
            Unique(),
        field.String("last_name").
            NotEmpty(),
            field.String("first_name").
            NotEmpty(),
        field.String("email").
            NotEmpty().
            Unique(),
        field.Time("created_at").
            SchemaType(map[string]string{
                "mysql": "datetime",
            }).
            Default(time.Now).
            Immutable(),
        field.Time("updated_at").
            SchemaType(map[string]string{
                "mysql": "datetime",
            }).
            Default(time.Now).
            UpdateDefault(time.Now),
        field.Time("deleted_at").
            SchemaType(map[string]string{
                "mysql": "datetime",
            }).
            Nillable().
            Optional(),
    }
}

func (User) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("deleted_at"),
    }
}

func (User) Edges() []ent.Edge {
    return nil
}
※DBにMySQLを使っている場合、日付項目はdatetime型にする必要があります。

 

次に以下のコマンドを実行し、ent.関連ファイルを自動生成します。
$ docker compose exec api go generate ./ent
※追加や更新の場合は上記コマンドで変更が反映されますが、削除の場合は反映されないのでご注意下さい。

 

次にファイル「api/src/ent/generate.go」のコメント部分を次のように修正します。
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/versioned-migration ./schema

 

次に以下のコマンドを実行し、atlasでマイグレーションファイルを作成するためのディレクトリとファイルを作成します。
$ mkdir -p src/ent/migrate/migrations && touch src/ent/migrate/main.go

 

次に作成したファイル「src/ent/migrate/main.go」を以下のように記述します。

//go:build ignore

package main

import (
    "context"
    "log"
    "os"

    "api/ent/migrate"
    "api/database"

    atlas "ariga.io/atlas/sql/migrate"
    "entgo.io/ent/dialect"
    "entgo.io/ent/dialect/sql/schema"
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    dsn, _ := database.GetDsnForMigrate("")
    ctx := context.Background()
    // Create a local migration directory able to understand Atlas migration file format for replay.
    dir, err := atlas.NewLocalDir("ent/migrate/migrations")
    if err != nil {
        log.Fatalf("failed creating atlas migration directory: %v", err)
    }
    // Migrate diff options.
    opts := []schema.MigrateOption{
        schema.WithDir(dir), // provide migration directory
        schema.WithMigrationMode(schema.ModeInspect), // provide migration mode
        schema.WithDialect(dialect.MySQL), // Ent dialect to use
        schema.WithFormatter(atlas.DefaultFormatter),
    }
    if len(os.Args) != 2 {
        log.Fatalln("migration name is required. Use: 'go run -mod=mod ent/migrate/main.go <name>'")
    }
    // Generate migrations using Atlas support for MySQL (note the Ent dialect option passed above).
    err = migrate.NamedDiff(ctx, dsn, os.Args[1], opts...)
    if err != nil {
        log.Fatalf("failed generating migration file: %v", err)
    }
}

 

次に以下のコマンドを実行し、atlasでマイグレーションファイルを作成します。

$ docker compose exec api go run -mod=mod ent/migrate/main.go add_user

 

コマンド実行後、ディレクトリ「src/ent/migrate/migrations」にマイグレーションファイルが作成されればOKです。

 

次に以下のコマンドを実行し、マイグレーションファイルを実行してDBにユーザーテーブルを作成します。

$ docker compose exec api atlas migrate apply \
$ --dir "file://ent/migrate/migrations" \
$ --url mysql://echo-user:echo-password@db:3306/echo-db

※「–dir “file://ent/migrate/migrations”」でマイグレーションファイルの格納先、「–url mysql://echo-user:echo-password@db:3306/echo-db」でDBの接続先を指定しています。

 

マイグレーション実行後、正常終了すればOKです。

 

作成したマイグレーションファイルを修正したい場合について

atlasで作成したマイグレーションファイルについては、ファイル「atlas.sum 」のハッシュ値でも管理されているため、修正したい場合はマイグレーションファイルを削除するだけでなく、ファイル「atlas.sum 」にある対象のハッシュ値の行を削除した後、以下のコマンドを実行してハッシュの修正が必要です。

$ docker compose exec api atlas migrate hash --dir "file://ent/migrate/migrations"

 

スポンサーリンク

CRUD処理のユーザーAPIを作成

次にユーザーテーブルにデータを作成したりするためのAPIを作成してみます。以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir -p src/internal/repositories/user && touch src/internal/repositories/user/user.go
$ mkdir -p src/internal/services/user && touch src/internal/services/user/user.go
$ mkdir -p src/internal/handlers/user && touch src/internal/handlers/user/user.go

 

次に作成したファイルについて、それぞれ以下のように記述します。

package user

import (
    "context"
    "fmt"
    "time"

    "api/ent"
    entUser "api/ent/user"
)

func CreateUser(
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
    lastName string,
    firstName string,
    email string,
) (*ent.User, error) {

    user, err := dbClient.User.
                          Create().
                          SetUID(uid).
                          SetLastName(lastName).
                          SetFirstName(firstName).
                          SetEmail(email).
                          Save(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed creating user: %v", err)
    }

    return user, nil
}

func GetUserFromEmail(
    dbCtx context.Context,
    dbClient *ent.Client,
    email string,
) (*ent.User, error) {

    user, err := dbClient.User.
                          Query().
                          Where(entUser.EmailEQ(email)).
                          Where(entUser.DeletedAtIsNil()).
                          First(dbCtx)
    if err != nil && !ent.IsNotFound(err) {
        return nil, fmt.Errorf("failed get user: %v", err)
    }

    return user, nil
}

func GetUserFromUid(
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
) (*ent.User, error) {

    user, err := dbClient.User.
                          Query().
                          Where(entUser.UIDEQ(uid)).
                          Where(entUser.DeletedAtIsNil()).
                          First(dbCtx)
    if err != nil && !ent.IsNotFound(err) {
        return nil, fmt.Errorf("failed get user: %v", err)
    }

    return user, nil
}

func GetUsers(
    dbCtx context.Context,
    dbClient *ent.Client,
) ([]*ent.User, error) {

    users, err := dbClient.User.
                           Query().
                           Where(entUser.DeletedAtIsNil()).
                           All(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed get users: %v", err)
    }

    return users, nil
}

func UpdateUser(
    dbCtx context.Context,
    dbClient *ent.Client,
    user *ent.User,
    lastName string,
    firstName string,
    email string,
) (*ent.User, error) {

    updateOneUser := dbClient.User.
                              UpdateOne(user)

    if lastName != "" {
        updateOneUser = updateOneUser.SetLastName(lastName)
    }

    if firstName != "" {
        updateOneUser = updateOneUser.SetFirstName(firstName)
    }

    if email != "" {
        updateOneUser = updateOneUser.SetEmail(email)
    }

    user, err := updateOneUser.Save(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed update user: %v", err)
    }

    return user, nil
}

func DeleteUser(
    dbCtx context.Context,
    dbClient *ent.Client,
    user *ent.User,
) error {
    // 現在の日時を文字列で取得
    date := time.Now()
    dateString := date.Format("2006-01-02 15:04:05")

    // 更新用のemailの値を設定
    updateEmail := user.Email + dateString

    _, err := dbClient.User.
                       UpdateOne(user).
                       SetEmail(updateEmail).
                       SetDeletedAt(time.Now()).
                       Save(dbCtx)
    if err != nil {
        return fmt.Errorf("failed delete user: %v", err)
    }

    return nil
}

 

package user

import (
    "context"
    "fmt"

    "api/ent"
    "api/internal/repositories/user"
)

type UserService interface {
    CreateUser(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        uid string,
        lastName string,
        firstName string,
        email string,
    ) (*ent.User, error)
    GetUser(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        email string,
    ) (UserResponse, error)
    GetUsers(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
    ) ([]*ent.User, error)
    UpdateUser(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        uid string,
        lastName string,
        firstName string,
        email string,
    ) (UserResponse, error)
    DeleteUser(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        uid string,
    ) error
}

type userService struct{}

func NewUserService() UserService {
    return &userService{}
}

// レスポンス定義
type User struct { *ent.User }
type EmptyResponse map[string]interface{}
type UserResponse interface {}
func (u User) UserResponse() {}
func (e EmptyResponse) UserResponse() {}

func (s *userService) CreateUser(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
    lastName string,
    firstName string,
    email string,
) (*ent.User, error) {

    createUser, err := user.CreateUser(
                           dbCtx,
                           dbClient,
                           uid,
                           lastName,
                           firstName,
                           email,
                       )
    if err != nil {
        return nil, err
    }

    return createUser, nil
}

func (s *userService) GetUser(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
) (UserResponse, error) {

    getUser, err := user.GetUserFromUid(
                        dbCtx,
                        dbClient,
                        uid,
                    )
    if err != nil {
        return nil, err
    }

    if getUser == nil {
        emptyResponse := map[string]interface{}{}
        return emptyResponse, nil
    }

    return getUser, nil
}

func (s *userService) GetUsers(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
) ([]*ent.User, error) {

    getUsers, err := user.GetUsers(
                         dbCtx,
                         dbClient,
                     )
    if err != nil {
        return nil, err
    }

    return getUsers, nil
}

func (s *userService) UpdateUser(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
    lastName string,
    firstName string,
    email string,
) (UserResponse, error) {

    getUser, err := user.GetUserFromUid(
                        dbCtx,
                        dbClient,
                        uid,
                    )
    if err != nil {
        return nil, err
    }

    if getUser == nil {
        return nil, fmt.Errorf("no user")
    }

    updateUser, err := user.UpdateUser(
                           dbCtx,
                           dbClient,
                           getUser,
                           lastName,
                           firstName,
                           email,
                       )
    if err != nil {
        return nil, err
    }

    return updateUser, nil
}

func (s *userService) DeleteUser(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
) error {

    getUser, err := user.GetUserFromUid(
                        dbCtx,
                        dbClient,
                        uid,
                    )
    if err != nil {
        return err
    }

    if getUser == nil {
        return fmt.Errorf("no user")
    }

    err = user.DeleteUser(
              dbCtx,
              dbClient,
              getUser,
          )
    if err != nil {
        return err
    }

    return nil
}

 

package user

import (
    "context"
    "net/http"
    "time"

    "api/database"
    "api/internal/services/user"

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

type CreateUserRequestBody struct {
    Uid string `json:"uid" form:"uid" validate:"required" example:"Ax1abc9uiowd9lKE"`
    LastName string `json:"last_name" form:"last_name" validate:"required" example:"田中"`
    FirstName string `json:"first_name" form:"first_name" validate:"required" example:"太郎"`
    Email string `json:"email" form:"email" validate:"required,email" example:"taro@example.com"`
}

type UpdateUserRequestBody struct {
    LastName string `json:"last_name" form:"last_name" example:"田中"`
    FirstName string `json:"first_name" form:"first_name" example:"太郎"`
    Email string `json:"email" form:"email" validate:"omitempty,email" example:"taro@example.com"`
}

type userHandler struct {
    userService user.UserService
}

func NewUserHandler(e *echo.Echo) *userHandler {
    return &userHandler{
        userService: user.NewUserService(),
    }
}

type MessageResponse struct {
    Message string `json:"message"`
}

type UserResponse struct {
    ID int64 `json:"id" example:"1"`
    UID string `json:"uid" example:"Xa12kK9ohsIhldD4"`
    LastName string `json:"last_name" example:"田中"`
    FirstName string `json:"first_name" example:"太郎"`
    Email string `json:"email" example:"taro@example.com"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
}

// @Description ユーザー作成
// @Tags user
// @Param CreateUser body CreateUserRequestBody true "作成するユーザー情報"
// @Success 201 {object} UserResponse ユーザー情報
// @Failure 400
// @Failure 500
// @Router /user [post]
func (h *userHandler) CreateUser(c echo.Context) error {
    var r CreateUserRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー作成
    res, err := h.userService.CreateUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    r.Uid,
                    r.LastName,
                    r.FirstName,
                    r.Email,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Create User")
    }

    return c.JSON(http.StatusCreated, res)
}

// @Description 有効な対象ユーザー取得
// @Tags user
// @Param uid path string true "uid"
// @Success 200 {object} UserResponse ユーザー情報
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [get]
func (h *userHandler) GetUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 対象ユーザー取得
    res, err := h.userService.GetUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    uid,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 有効な全てのユーザー取得
// @Tags user
// @Success 200 {object} []UserResponse ユーザー情報
// @Failure 500
// @Router /users [get]
func (h *userHandler) GetUsers(c echo.Context) error {
    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 全てのユーザー取得
    res, err := h.userService.GetUsers(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象ユーザー更新
// @Tags user
// @Param UpdateUser body UpdateUserRequestBody true "更新するユーザー情報"
// @Success 200 {object} UserResponse ユーザー情報
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [put]
func (h *userHandler) UpdateUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    var r UpdateUserRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー更新
    res, err := h.userService.UpdateUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    uid,
                    r.LastName,
                    r.FirstName,
                    r.Email,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象ユーザー削除
// @Tags user
// @Param uid path string true "uid"
// @Success 200 {object} MessageResponse メッセージ
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [delete]
func (h *userHandler) DeleteUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー削除
    err = h.userService.DeleteUser(
              c.Request().Context(),
              dbCtx,
              dbClient,
              uid,
          )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

    res := MessageResponse{ Message: "OK" }

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

 

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

package router

import (
    "github.com/labstack/echo/v4"
    "api/internal/handlers/index"
    "api/internal/handlers/user"
)

func SetupRouter(e *echo.Echo) {
    indexHandler := index.NewIndexHandler(e)
    e.GET("/", indexHandler.GetIndex)

    v1 := e.Group("/api/v1")
    v1.POST("/hello", indexHandler.PostIndex)

    userHandler := user.NewUserHandler(e)
    v1.POST("/user", userHandler.CreateUser)
    v1.GET("/user/:uid", userHandler.GetUser)
    v1.GET("/users", userHandler.GetUsers)
    v1.PUT("/user/:uid", userHandler.UpdateUser)
    v1.DELETE("/user/:uid", userHandler.DeleteUser)
}

 

次に以下のコマンドを実行し、modファイルを更新しておきます。

$ docker compose exec api go mod tidy

 

ユーザーAPIのCRUD処理を試す

次に作成したユーザーAPIをPostmanで試してみます。

ではPostmanからPOSTメソッドで「http://localhost/api/v1/user」を実行し、正常終了すればOKです。

 

次にGETメソッドで「http://localhost/api/v1/user/対象のuid」を実行し、上記で作成したユーザー情報が取得できればOKです。

 

次にPUTメソッドで「http://localhost/api/v1/user/対象のuid」を実行し、ユーザー情報が更新できればOKです。

 

次にDELETEメソッドで「http://localhost/api/v1/user/対象のuid」を実行し、正常終了すればOKです。

 

ユーザー削除後、もう一度GETメソッドで「http://localhost/api/v1/user/対象のuid」を実行し、空のオブジェクトが取得できればOKです。

 

スポンサーリンク

認証機能の追加

次にFirebase Authenticationによる認証機能を追加してみます。

ここではFirebase Authenticationの詳細は割愛させていただきますが、プロジェクトの設定にあるサービスアカウトから「新しい秘密鍵を生成」をクリックし、認証情報を取得して確認して下さい。

 

次にFirebaseの認証情報は環境変数に設定するため、「api/.env」を以下のように修正します。

※追加項目の値は機密情報のため、上記で取得した値を各自で設定して下さい。

ENV=local
MYSQL_ROOT_PASSWORD=root-password
MYSQL_USER=echo-user
MYSQL_PASSWORD=echo-password
TZ=Asia/Tokyo
FIREBASE_TYPE=
FIREBASE_PROJECT_ID=
FIREBASE_PRIVATE_KEY_ID=
FIREBASE_PRIVATE_KEY=
FIREBASE_CLIENT_EMAIL=
FIREBASE_CLIENT_ID=
FIREBASE_AUTH_URI=
FIREBASE_TOKEN_URI=
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=
FIREBASE_CLIENT_X509_CERT_URL=
FIREBASE_UNIVERSE_DOMAIN=

 

次にコンフィグ設定を以下のように修正します。

package config

import (
    "os"
    "github.com/spf13/viper"
    "github.com/fsnotify/fsnotify"
    "fmt"
)

type Config struct {
    Env string
    Db  Database `yml:db`
    Fb  Firebase
}

type Database struct {
    UserName  string
    Password  string
    Host      string `yml:host`
    Port      int    `yml:port`
    Database  string `yml:database`
    Charset   string `yml:charset`
    Collation string `yml:collation`
    ParseTime bool   `yml:parseTime`
    Loc       string
}

type Firebase struct {
    Type                    string
    ProjectId               string
    PrivateKeyId            string
    PrivateKey              string
    ClientEmail             string
    ClientId                string
    AuthUri                 string
    TokenUri                string
    AuthProviderX509CertUrl string
    ClientX509CertUrl       string
    UniverseDomain          string
}
func SetupConfig(env string) (*Config, error) {

    var config *Config

    if env != "testing" {
        env = os.Getenv("ENV")
    }

    user := os.Getenv("MYSQL_USER")
    password := os.Getenv("MYSQL_PASSWORD")
    tz := os.Getenv("TZ")

    // Firebase
    fbType := os.Getenv("FIREBASE_TYPE")
    fbProjectId := os.Getenv("FIREBASE_PROJECT_ID")
    fbPrivateKeyId := os.Getenv("FIREBASE_PRIVATE_KEY_ID")
    fbPrivateKey := os.Getenv("FIREBASE_PRIVATE_KEY")
    fbClientEmail := os.Getenv("FIREBASE_CLIENT_EMAIL")
    fbClientId := os.Getenv("FIREBASE_CLIENT_ID")
    fbAuthUri := os.Getenv("FIREBASE_AUTH_URI")
    fbTokenUri := os.Getenv("FIREBASE_TOKEN_URI")
    fbAuthProviderX509CertUrl := os.Getenv("FIREBASE_AUTH_PROVIDER_X509_CERT_URL")
    fbClientX509CertUrl := os.Getenv("FIREBASE_CLIENT_X509_CERT_URL")
    fbUniverseDomain := os.Getenv("FIREBASE_UNIVERSE_DOMAIN")

    // viperの初期設定
    viper.SetConfigName("config." + env)
    viper.SetConfigType("yml")
    viper.AddConfigPath("config/")

    // configファイルの読み込み
    err := viper.ReadInConfig()
    if err != nil {
        panic(err)
    }

    // 読み込んだデータを変数cfgに設定
    err = viper.Unmarshal(&config)
    if err != nil {
        panic(err)
    }

    // configファイル更新時に再読み込み
    viper.WatchConfig()
    viper.OnConfigChange(func(e fsnotify.Event) {
        fmt.Println("Config file changed:", e.Name)
    })

    // 環境変数の値を変数cfgに設定
    config.Env = env
    config.Db.UserName = user
    config.Db.Password = password
    config.Db.Loc = tz

    // Firebase
    config.Fb.Type = fbType
    config.Fb.ProjectId = fbProjectId
    config.Fb.PrivateKeyId = fbPrivateKeyId
    config.Fb.PrivateKey = fbPrivateKey
    config.Fb.ClientEmail = fbClientEmail
    config.Fb.ClientId = fbClientId
    config.Fb.AuthUri = fbAuthUri
    config.Fb.TokenUri = fbTokenUri
    config.Fb.AuthProviderX509CertUrl = fbAuthProviderX509CertUrl
    config.Fb.ClientX509CertUrl = fbClientX509CertUrl
    config.Fb.UniverseDomain = fbUniverseDomain

    return config, nil
}

 

次に以下のコマンドを実行し、Firebaseの設定ファイルを作成します。

$ mkdir -p src/config/firebase && touch src/config/firebase/firebase.go

 

次に作成したファイルについて以下のように記述します。

package firebase

import (
    "context"
    "fmt"

    "api/config"

    "firebase.google.com/go/v4/auth"
    "google.golang.org/api/option"
    firebase "firebase.google.com/go/v4"
)

func SetupFirebase() (*auth.Client, error) {
    // コンフィグ設定の取得
    cfg, err := config.SetupConfig("")
    if err != nil {
        fmt.Println("コンフィグ設定取得エラー", err)
        return nil, err
    }

    // Firebase接続設定
    opt := option.WithCredentialsJSON([]byte(fmt.Sprintf(
               `{
                   "type": "%s",
                   "project_id": "%s",
                   "private_key_id": "%s",
                   "private_key": %q,
                   "client_email": "%s",
                   "client_id": "%s",
                   "auth_uri": "%s",
                   "token_uri": "%s",
                   "auth_provider_x509_cert_url": "%s",
                   "client_x509_cert_url": "%s",
                   "universe_domain": "%s"
               }`,
               cfg.Fb.Type,
               cfg.Fb.ProjectId,
               cfg.Fb.PrivateKeyId,
               cfg.Fb.PrivateKey,
               cfg.Fb.ClientEmail,
               cfg.Fb.ClientId,
               cfg.Fb.AuthUri,
               cfg.Fb.TokenUri,
               cfg.Fb.AuthProviderX509CertUrl,
               cfg.Fb.ClientX509CertUrl,
               cfg.Fb.UniverseDomain,
           )))

    // FirebaseのAppを初期化
    app, err := firebase.NewApp(context.Background(), nil, opt)
    if err != nil {
        return nil, fmt.Errorf("error initializing app: %v", err)
    }

    // FirebaseのAuthクライアントを初期化
    client, err := app.Auth(context.Background())
    if err != nil {
        return nil, fmt.Errorf("error getting Auth client: %v", err)
    }

    return client, nil
}

 

次に以下のコマンドを実行し、認証用のミドルウェアを作成します。

$ mkdir -p src/internal/middleware/auth && touch src/internal/middleware/auth/auth.go

 

次に作成したファイルについて以下のように記述します。

package auth

import (
    "context"
    "strings"
    "net/http"

    "api/config/firebase"

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

func FirebaseAuth(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        // AuthorizationヘッダーからidTokenを取得
        idToken := ""
        authHeader := c.Request().Header.Get("Authorization")
        if authHeader != "" {
            idToken = strings.Replace(authHeader, "Bearer ", "", 1)
        } else {
            return c.JSON(http.StatusUnauthorized, map[string]string{"message": "Unauthorized"})
        }

        // Firebaseのclient取得
        client, err := firebase.SetupFirebase()
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{"message": err.Error()})
        }

        // idTokenを検証
        token, err := client.VerifyIDToken(context.Background(), idToken)
        if err != nil {
            return c.JSON(http.StatusUnauthorized, map[string]string{"message": "Unauthorized"})
        }

        // idTokenからuidを取得
        uid := token.UID

        // uidからユーザー情報取得
        firebaseUser, err := client.GetUser(context.Background(), uid)
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{"message": err.Error()})
        }

        // ユーザー情報からemailを取得
        email := firebaseUser.Email

        // uidとemailをContextに保存
        c.Set("uid", uid)
        c.Set("email", email)

        return next(c)
    }
}

 

次に特定のエンドポイントだけにミドルウェアを設定するため、ルーターファイル「api/src/router/router.go」を次のように修正します。

package router

import (
    "github.com/labstack/echo/v4"
    "api/internal/handlers/index"
    "api/internal/handlers/user"
    "api/internal/middleware/auth"
)

func SetupRouter(e *echo.Echo) {
    indexHandler := index.NewIndexHandler(e)
    e.GET("/", indexHandler.GetIndex)

    v1 := e.Group("/api/v1")
    v1.POST("/hello", indexHandler.PostIndex)

    userHandler := user.NewUserHandler(e)
    v1.POST("/user", userHandler.CreateUser)
    v1.GET("/user/:uid", userHandler.GetUser, auth.FirebaseAuth)
    v1.GET("/users", userHandler.GetUsers)
    v1.PUT("/user/:uid", userHandler.UpdateUser)
    v1.DELETE("/user/:uid", userHandler.DeleteUser)
}

※例としてユーザー取得APIにだけ認証用のミドルウェアを設定します。

 

次に以下のコマンドを実行し、modファイルの更新およびコンテナの再起動を行います。

$ docker compose exec api go mod tidy
$ docker compose down
$ docker compose build --no-cache
$ docker compose up -d

 

次にFirebaseで事前にユーザーを作成するため、Firebase Authenticationのユーザー画面から「ユーザーを追加」をクリックします。

 

次にメールとパスワードを入力し、「ユーザーを追加」をクリックします。

 

これでFirebase Authenticationにユーザーが作成されるので、対象ユーザーの右側にあるボタンからUIDをコピーして使用して下さい。

 

次に上記で登録したメールアドレスおよび登録後に取得したUIDを使い、ユーザー作成APIで新規ユーザーを作成します。

 

これで認証機能を試す準備ができたので、認証機能を付けたユーザー取得APIをそのまま実行し、401エラーになればOKです。

 

次にFirebase Auth REST APIを使ってAPI実行時に付与するidTokenを取得します。

POSTメソッドでURLが「https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=FirebaseのAPIキー」、ボディに上記で作成したFirebaseユーザーのemailとpasswordおよびreturnSecureTokenに「true」を指定して実行し、idTokenを取得できればOKです。

※URLにあるFirebaseのAPIキーは、Firebaseのプロジェクトの設定の全般画面から確認して設定して下さい。

 

次にユーザー取得APIで、タブ「認可」からAuth Typeに「Bearerトークン」を選び、トークンに上記で取得したidTokenを設定後にAPIを実行し、対象データが取得できればOKです。

 

認可処理を追加

上記で認証機能を付けましたが、有効なidTokenさせ付与すれば自分意外のデータも取得できてしまうので、それをできなくするために認可処理を追加します。

ユーザーのハンドラーファイル「api/src/internal/handlers/user/user.go」のGetUserメソッドを以下のように修正します。

package user

import (
    "context"
    "net/http"
    "time"

    "api/database"
    "api/internal/services/user"

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

type CreateUserRequestBody struct {
    Uid string `json:"uid" form:"uid" validate:"required" example:"Ax1abc9uiowd9lKE"`
    LastName string `json:"last_name" form:"last_name" validate:"required" example:"田中"`
    FirstName string `json:"first_name" form:"first_name" validate:"required" example:"太郎"`
    Email string `json:"email" form:"email" validate:"required,email" example:"taro@example.com"`
}

type UpdateUserRequestBody struct {
    LastName string `json:"last_name" form:"last_name" example:"田中"`
    FirstName string `json:"first_name" form:"first_name" example:"太郎"`
    Email string `json:"email" form:"email" validate:"omitempty,email" example:"taro@example.com"`
}

type userHandler struct {
    userService user.UserService
}

func NewUserHandler(e *echo.Echo) *userHandler {
    return &userHandler{
        userService: user.NewUserService(),
    }
}

type MessageResponse struct {
    Message string `json:"message"`
}

type UserResponse struct {
    ID int64 `json:"id" example:"1"`
    UID string `json:"uid" example:"Xa12kK9ohsIhldD4"`
    LastName string `json:"last_name" example:"田中"`
    FirstName string `json:"first_name" example:"太郎"`
    Email string `json:"email" example:"taro@example.com"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
}

// @Description ユーザー作成
// @Tags user
// @Param CreateUser body CreateUserRequestBody true "作成するユーザー情報"
// @Success 201 {object} UserResponse ユーザー情報
// @Failure 400
// @Failure 500
// @Router /user [post]
func (h *userHandler) CreateUser(c echo.Context) error {
    var r CreateUserRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー作成
    res, err := h.userService.CreateUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    r.Uid,
                    r.LastName,
                    r.FirstName,
                    r.Email,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Create User")
    }

    return c.JSON(http.StatusCreated, res)
}

// @Description 有効な対象ユーザー取得
// @Tags user
// @Param uid path string true "uid"
// @Success 200 {object} UserResponse ユーザー情報
// @Failure 401
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [get]
func (h *userHandler) GetUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    // 認可チェック
    if uid != c.Get("uid") {
        return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 対象ユーザー取得
    res, err := h.userService.GetUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    uid,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 有効な全てのユーザー取得
// @Tags user
// @Success 200 {object} []UserResponse ユーザー情報
// @Failure 500
// @Router /users [get]
func (h *userHandler) GetUsers(c echo.Context) error {
    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 全てのユーザー取得
    res, err := h.userService.GetUsers(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象ユーザー更新
// @Tags user
// @Param UpdateUser body UpdateUserRequestBody true "更新するユーザー情報"
// @Success 200 {object} UserResponse ユーザー情報
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [put]
func (h *userHandler) UpdateUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    var r UpdateUserRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー更新
    res, err := h.userService.UpdateUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    uid,
                    r.LastName,
                    r.FirstName,
                    r.Email,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象ユーザー削除
// @Tags user
// @Param uid path string true "uid"
// @Success 200 {object} MessageResponse メッセージ
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [delete]
func (h *userHandler) DeleteUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー削除
    err = h.userService.DeleteUser(
              c.Request().Context(),
              dbCtx,
              dbClient,
              uid,
          )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

    res := MessageResponse{ Message: "OK" }

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

 

これで認可処理を試す準備ができたので、別途Freabaseで別のユーザーを作成してidTokenを取得後、そのidTokenを使って上記のユーザー取得APIを実行し、401エラーになればOKです。

 

スポンサーリンク

ent.でリレーションを試す

次にユーザーが投稿するメッセージを管理するPostテーブルを作成し、「ent.」でテーブルのリレーションを試します。

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

$ docker compose exec api go run -mod=mod entgo.io/ent/cmd/ent init Post

 

次に作成されたファイルについて、以下のように修正します。

package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/index"
    "entgo.io/ent/schema/edge"
)

type Post struct {
    ent.Schema
}

func (Post) Fields() []ent.Field {
    return []ent.Field{
        field.Int64("id"),
        field.Int64("user_id").
              Optional(),
        field.String("text").
              NotEmpty(),
        field.Time("created_at").
              SchemaType(map[string]string{
                  "mysql": "datetime",
              }).
              Default(time.Now).
              Immutable(),
        field.Time("updated_at").
              SchemaType(map[string]string{
                  "mysql": "datetime",
              }).
              Default(time.Now).
              UpdateDefault(time.Now),
        field.Time("deleted_at").
              SchemaType(map[string]string{
                  "mysql": "datetime",
              }).
              Nillable().
              Optional(),
    }
}

func (Post) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("user_id"),
        index.Fields("deleted_at"),
    }
}

func (Post) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("users", User.Type).
             Ref("posts").
             Field("user_id").
             Unique(),
    }
}
※ent.ではEdgesを使ってリレーションを定義します。またRefとFieldを使って外部キーを指定しています。

 

次にユーザーのスキーマ定義を次のように修正します。
package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/index"
    "entgo.io/ent/schema/edge"
)

type User struct {
   ent.Schema
}

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.Int64("id"),
        field.String("uid").
            NotEmpty().
            Unique(),
        field.String("last_name").
            NotEmpty(),
            field.String("first_name").
            NotEmpty(),
        field.String("email").
            NotEmpty().
            Unique(),
        field.Time("created_at").
            SchemaType(map[string]string{
                "mysql": "datetime",
            }).
            Default(time.Now).
            Immutable(),
        field.Time("updated_at").
            SchemaType(map[string]string{
                "mysql": "datetime",
            }).
            Default(time.Now).
            UpdateDefault(time.Now),
        field.Time("deleted_at").
            SchemaType(map[string]string{
                "mysql": "datetime",
            }).
            Nillable().
            Optional(),
    }
}

func (User) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("deleted_at"),
    }
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("posts", Post.Type),
    }
}
※User→PostへのリレーションはUserでedge.Toだけあればできますが、外部キーを指定したい場合はPost側にedge.Fromが必要です。(edge.Toだけの場合は自動で外部キー項目が作成されますが、そのキーに対して直接Indexの作成ができなかったため、Post側にカラム「user_id」を作って外部キーに指定するようにしました。)

 

次に以下のコマンドを実行し、マイグレーションファイルを作成します。

$ docker compose exec api go generate ./ent
$ docker compose exec api go run -mod=mod ent/migrate/main.go add_post

 

次に以下のコマンドを実行し、マイグレーションを行います。

$ docker compose exec api atlas migrate apply \
$ --dir "file://ent/migrate/migrations" \
$ --url mysql://echo-user:echo-password@db:3306/echo-db

 

次に以下のコマンドを実行し、PostAPI用の各種ファイルを作成します。

$ mkdir -p src/internal/repositories/post && touch src/internal/repositories/post/post.go
$ mkdir -p src/internal/services/post && touch src/internal/services/post/post.go
$ mkdir -p src/internal/handlers/post && touch src/internal/handlers/post/post.go

 

次に作成したファイルについて、それぞれ以下のように記述します。

package post

import (
    "context"
    "fmt"
    "time"

    "api/ent"
    entPost "api/ent/post"
    entUser "api/ent/user"
)

func CreatePost(
    dbCtx context.Context,
    dbClient *ent.Client,
    userId int64,
    text string,
) (*ent.Post, error) {

    post, err := dbClient.Post.
                          Create().
                          SetUserID(userId).
                          SetText(text).
                          Save(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed creating post: %v", err)
    }

    return post, nil
}

func GetPostFromId(
    dbCtx context.Context,
    dbClient *ent.Client,
    id int64,
) (*ent.Post, error) {

    post, err := dbClient.Post.
                          Query().
                          Where(entPost.IDEQ(id)).
                          Where(entPost.DeletedAtIsNil()).
                          WithUsers(func(query *ent.UserQuery) {
                              query.Where(entUser.DeletedAtIsNil())
                          }).
                          First(dbCtx)
    if err != nil && !ent.IsNotFound(err) {
        return nil, fmt.Errorf("failed get post: %v", err)
    }

    return post, nil
}

func DeletePost(
    dbCtx context.Context,
    dbClient *ent.Client,
    post *ent.Post,
) error {

    _, err := dbClient.Post.
                       UpdateOne(post).
                       SetDeletedAt(time.Now()).
                       Save(dbCtx)
    if err != nil {
        return fmt.Errorf("failed delete post: %v", err)
    }

    return nil
}

 

package post

import (
    "context"
    "fmt"

    "api/ent"
    "api/internal/repositories/post"
)

type PostService interface {
    CreatePost(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        userId int64,
        text string,
    ) (*ent.Post, error)
    GetPost(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        id int64,
    ) (PostResponse, error)
    DeletePost(
        echoCtx context.Context,
        dbCtx context.Context,
        dbClient *ent.Client,
        id int64,
    ) error
}

type postService struct{}

func NewPostService() PostService {
    return &postService{}
}

// レスポンス定義
type Post struct { *ent.Post }
type EmptyResponse map[string]interface{}
type PostResponse interface {}
func (u Post) PostResponse() {}
func (e EmptyResponse) PostResponse() {}

func (s *postService) CreatePost(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    userId int64,
    text string,
) (*ent.Post, error) {

    createPost, err := post.CreatePost(
                           dbCtx,
                           dbClient,
                           userId,
                           text,
                       )
    if err != nil {
        return nil, err
    }

    return createPost, nil
}

func (s *postService) GetPost(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    id int64,
) (PostResponse, error) {

    getPost, err := post.GetPostFromId(
                        dbCtx,
                        dbClient,
                        id,
                    )
    if err != nil {
        return nil, err
    }

    if getPost == nil {
        emptyResponse := map[string]interface{}{}
        return emptyResponse, nil
    }

    return getPost, nil
}

func (s *postService) DeletePost(
    echoCtx context.Context,
    dbCtx context.Context,
    dbClient *ent.Client,
    id int64,
) error {

    getPost, err := post.GetPostFromId(
                        dbCtx,
                        dbClient,
                        id,
                    )
    if err != nil {
        return err
    }

    if getPost == nil {
        return fmt.Errorf("no post")
    }

    err = post.DeletePost(
              dbCtx,
              dbClient,
              getPost,
          )
    if err != nil {
        return err
    }

    return nil
}

 

package post

import (
    "context"
    "net/http"
    "time"
    "strconv"

    "api/database"
    "api/internal/services/post"

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

type CreatePostRequestBody struct {
    UserId int64 `json:"user_id" form:"user_id" validate:"required" example:"1"`
    Text string `json:"text" form:"text" validate:"required" example:"こんんちは。"`
}

type postHandler struct {
    postService post.PostService
}

func NewPostHandler(e *echo.Echo) *postHandler {
    return &postHandler{
        postService: post.NewPostService(),
    }
}

type MessageResponse struct {
    Message string `json:"message"`
}

type PostResponse struct {
    ID int64 `json:"id" example:"1"`
    UserId int64 `json:"user_id" example:"1"`
    Text string `json:"text" example:"こんにちは。"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
    Edges struct {}
}

type User struct {
    ID int64 `json:"id" example:"1"`
    UID string `json:"uid" example:"Xa12kK9ohsIhldD4"`
    LastName string `json:"last_name" example:"田中"`
    FirstName string `json:"first_name" example:"太郎"`
    Email string `json:"email" example:"taro@example.com"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
    Edges struct {}
}

type PostResponseWithUser struct {
    ID int64 `json:"id" example:"1"`
    UserId int64 `json:"user_id" example:"1"`
    Text string `json:"text" example:"こんにちは。"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
    Edges struct {
        Users User `json:"users"`
    } `json:"edges"`
}

// @Description Post作成
// @Tags post
// @Param CreateUser body CreatePostRequestBody true "作成するPost情報"
// @Success 201 {object} PostResponse ポスト情報
// @Failure 400
// @Failure 500
// @Router /post [post]
func (h *postHandler) CreatePost(c echo.Context) error {
    var r CreatePostRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // Post作成
    res, err := h.postService.CreatePost(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    r.UserId,
                    r.Text,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Create Post")
    }

    return c.JSON(http.StatusCreated, res)
}

// @Description 有効な対象Post取得
// @Tags post
// @Param id path int64 true "id"
// @Success 200 {object} PostResponseWithUser ポスト情報(User含む)
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /post/{id} [get]
func (h *postHandler) GetPost(c echo.Context) error {
    // パスパラメータ取得
    idStr := c.Param("id")
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Id Conversion")
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 対象Post取得
    res, err := h.postService.GetPost(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    id,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象Post削除
// @Tags post
// @Param id path int64 true "id"
// @Success 200 {object} MessageResponse メッセージ
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /post/{id} [delete]
func (h *postHandler) DeletePost(c echo.Context) error {
    // パスパラメータ取得
    idStr := c.Param("id")
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Id Conversion")
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // Post削除
    err = h.postService.DeletePost(
              c.Request().Context(),
              dbCtx,
              dbClient,
              id,
          )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

    res := MessageResponse{ Message: "OK" }

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

 

次にユーザーAPI用の各種ファイルについて、それぞれ以下のように修正します。

package user

import (
    "context"
    "fmt"
    "time"

    "api/ent"
    entUser "api/ent/user"
    entPost "api/ent/post"
)

func CreateUser(
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
    lastName string,
    firstName string,
    email string,
) (*ent.User, error) {

    user, err := dbClient.User.
                          Create().
                          SetUID(uid).
                          SetLastName(lastName).
                          SetFirstName(firstName).
                          SetEmail(email).
                          Save(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed creating user: %v", err)
    }

    return user, nil
}

func GetUserFromEmail(
    dbCtx context.Context,
    dbClient *ent.Client,
    email string,
) (*ent.User, error) {

    user, err := dbClient.User.
                          Query().
                          Where(entUser.EmailEQ(email)).
                          Where(entUser.DeletedAtIsNil()).
                          First(dbCtx)
    if err != nil && !ent.IsNotFound(err) {
        return nil, fmt.Errorf("failed get user: %v", err)
    }

    return user, nil
}

func GetUserFromUid(
    dbCtx context.Context,
    dbClient *ent.Client,
    uid string,
) (*ent.User, error) {

    user, err := dbClient.User.
                          Query().
                          Where(entUser.UIDEQ(uid)).
                          Where(entUser.DeletedAtIsNil()).
                          WithPosts(func(query *ent.PostQuery) {
                              query.Where(entPost.DeletedAtIsNil())
                          }).
                          First(dbCtx)
    if err != nil && !ent.IsNotFound(err) {
        return nil, fmt.Errorf("failed get user: %v", err)
    }

    return user, nil
}

func GetUsers(
    dbCtx context.Context,
    dbClient *ent.Client,
) ([]*ent.User, error) {

    users, err := dbClient.User.
                           Query().
                           Where(entUser.DeletedAtIsNil()).
                           All(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed get users: %v", err)
    }

    return users, nil
}

func UpdateUser(
    dbCtx context.Context,
    dbClient *ent.Client,
    user *ent.User,
    lastName string,
    firstName string,
    email string,
) (*ent.User, error) {

    updateOneUser := dbClient.User.
                              UpdateOne(user)

    if lastName != "" {
        updateOneUser = updateOneUser.SetLastName(lastName)
    }

    if firstName != "" {
        updateOneUser = updateOneUser.SetFirstName(firstName)
    }

    if email != "" {
        updateOneUser = updateOneUser.SetEmail(email)
    }

    user, err := updateOneUser.Save(dbCtx)
    if err != nil {
        return nil, fmt.Errorf("failed update user: %v", err)
    }

    return user, nil
}

func DeleteUser(
    dbCtx context.Context,
    dbClient *ent.Client,
    user *ent.User,
) error {
    // 現在の日時を文字列で取得
    date := time.Now()
    dateString := date.Format("2006-01-02 15:04:05")

    // 更新用のemailの値を設定
    updateEmail := user.Email + dateString

    _, err := dbClient.User.
                       UpdateOne(user).
                       SetEmail(updateEmail).
                       SetDeletedAt(time.Now()).
                       Save(dbCtx)
    if err != nil {
        return fmt.Errorf("failed delete user: %v", err)
    }

    return nil
}

 

package user

import (
    "context"
    "net/http"
    "time"

    "api/database"
    "api/internal/services/user"

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

type CreateUserRequestBody struct {
    Uid string `json:"uid" form:"uid" validate:"required" example:"Ax1abc9uiowd9lKE"`
    LastName string `json:"last_name" form:"last_name" validate:"required" example:"田中"`
    FirstName string `json:"first_name" form:"first_name" validate:"required" example:"太郎"`
    Email string `json:"email" form:"email" validate:"required,email" example:"taro@example.com"`
}

type UpdateUserRequestBody struct {
    LastName string `json:"last_name" form:"last_name" example:"田中"`
    FirstName string `json:"first_name" form:"first_name" example:"太郎"`
    Email string `json:"email" form:"email" validate:"omitempty,email" example:"taro@example.com"`
}

type userHandler struct {
    userService user.UserService
}

func NewUserHandler(e *echo.Echo) *userHandler {
    return &userHandler{
        userService: user.NewUserService(),
    }
}

type MessageResponse struct {
    Message string `json:"message"`
}

type UserResponse struct {
    ID int64 `json:"id" example:"1"`
    UID string `json:"uid" example:"Xa12kK9ohsIhldD4"`
    LastName string `json:"last_name" example:"田中"`
    FirstName string `json:"first_name" example:"太郎"`
    Email string `json:"email" example:"taro@example.com"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
}

type Post struct {
    ID int64 `json:"id" example:"1"`
    UserID int64 `json:"user_id" example:"1"`
    Text string `json:"text" example:"こんにちは。"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
    Edges struct {}
}

type UserResponseWithPosts struct {
    ID int64 `json:"id" example:"1"`
    UID string `json:"uid" example:"Xa12kK9ohsIhldD4"`
    LastName string `json:"last_name" example:"田中"`
    FirstName string `json:"first_name" example:"太郎"`
    Email string `json:"email" example:"taro@example.com"`
    CreatedAt time.Time `json:"created_at" example:"2024-06-23T23:18:49+09:00"`
    UpdatedAt time.Time `json:"updated_at" example:"2024-06-23T23:18:49+09:00"`
    Edges struct {
        Posts []Post `json:"posts"`
    } `json:"edges"`
}

// @Description ユーザー作成
// @Tags user
// @Param CreateUser body CreateUserRequestBody true "作成するユーザー情報"
// @Success 201 {object} UserResponse ユーザー情報
// @Failure 400
// @Failure 500
// @Router /user [post]
func (h *userHandler) CreateUser(c echo.Context) error {
    var r CreateUserRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー作成
    res, err := h.userService.CreateUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    r.Uid,
                    r.LastName,
                    r.FirstName,
                    r.Email,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Create User")
    }

    return c.JSON(http.StatusCreated, res)
}

// @Description 有効な対象ユーザー取得
// @Tags user
// @Param uid path string true "uid"
// @Success 200 {object} UserResponseWithPosts ユーザー情報(Posts含む)
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [get]
func (h *userHandler) GetUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 対象ユーザー取得
    res, err := h.userService.GetUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    uid,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 有効な全てのユーザー取得
// @Tags user
// @Success 200 {object} []UserResponse ユーザー情報
// @Failure 500
// @Router /users [get]
func (h *userHandler) GetUsers(c echo.Context) error {
    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // 全てのユーザー取得
    res, err := h.userService.GetUsers(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象ユーザー更新
// @Tags user
// @Param UpdateUser body UpdateUserRequestBody true "更新するユーザー情報"
// @Success 200 {object} UserResponse ユーザー情報
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [put]
func (h *userHandler) UpdateUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    var r UpdateUserRequestBody
    if err := c.Bind(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
    }

    // バリデーションを実行
    if err := c.Validate(&r); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー更新
    res, err := h.userService.UpdateUser(
                    c.Request().Context(),
                    dbCtx,
                    dbClient,
                    uid,
                    r.LastName,
                    r.FirstName,
                    r.Email,
                )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

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

// @Description 対象ユーザー削除
// @Tags user
// @Param uid path string true "uid"
// @Success 200 {object} MessageResponse メッセージ
// @Failure 404
// @Failure 405
// @Failure 500
// @Router /user/{uid} [delete]
func (h *userHandler) DeleteUser(c echo.Context) error {
    // パスパラメータ取得
    uid := c.Param("uid")

    // DB設定
    dbCtx := context.Background()
    dbClient, err := database.SetupDatabase("")
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, "Failed Database Connection")
    }

    // ユーザー削除
    err = h.userService.DeleteUser(
              c.Request().Context(),
              dbCtx,
              dbClient,
              uid,
          )
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }

    res := MessageResponse{ Message: "OK" }

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

 

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

package router

import (
    "github.com/labstack/echo/v4"
    "api/internal/handlers/index"
    "api/internal/handlers/user"
    "api/internal/handlers/post"
    "api/internal/middleware/auth"
)

func SetupRouter(e *echo.Echo) {
    indexHandler := index.NewIndexHandler(e)
    e.GET("/", indexHandler.GetIndex)

    v1 := e.Group("/api/v1")
    v1.POST("/hello", indexHandler.PostIndex)

    userHandler := user.NewUserHandler(e)
    v1.POST("/user", userHandler.CreateUser)
    v1.GET("/user/:uid", userHandler.GetUser, auth.FirebaseAuth)
    v1.GET("/users", userHandler.GetUsers)
    v1.PUT("/user/:uid", userHandler.UpdateUser)
    v1.DELETE("/user/:uid", userHandler.DeleteUser)

    postHandler := post.NewPostHandler(e)
    v1.POST("/post", postHandler.CreatePost)
    v1.GET("/post/:id", postHandler.GetPost)
    v1.DELETE("/post/:id", postHandler.DeletePost)
}

 

次に以下のコマンドを実行し、modファイルの更新およびコンテナの再起動を行います。

$ docker compose exec api go mod tidy
$ docker compose down
$ docker compose build --no-cache
$ docker compose up -d

 

次に作成および修正したAPIをPostmanで試します。

ではPostmanからPOSTメソッドで「http://localhost/api/v1/post」を実行し、正常終了すればOKです。(レスポンス結果のidはメモしておきます)

 

次にGETメソッドで「http://localhost/api/v1/post/対象のid」を実行し、上記で作成したデータが取得できればOKです。

 

次にGETメソッドで「http://localhost/api/v1/user/対象のuid」を実行し、上記で作成したPostデータも合わせて取得できればOKです。

 

次にDELETEメソッドで「http://localhost/api/v1/post/対象のid」を実行し、正常終了すればOKです。

 

次にもう一度GETメソッドで「http://localhost/api/v1/user/対象のuid」を実行し、削除したPostデータが取得できなければOKです。

 

スポンサーリンク

最後に

今回はEchoでバックエンドAPIを開発する方法についてまとめました。

Echoの機能としてはまだまだ色々あるので、もう少し色々試す必要がありそうですが、とりあえず基本的なところは試せました。

echo-swaggerを使えば比較的簡単にAPI仕様書も作れることがわかったので、その点はよかったです。

また今回はEchoに加えてent.やatlasも試してみましたが、これらはまだまだ情報が少なくて使い方を理解するのに苦労したので、興味がある方はぜひ参考にしてみて下さい。

 

この記事を書いた人
Tomoyuki

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

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

コメント

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