こんにちは。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_HOST=db
MYSQL_DATABASE=echo-db
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"
}
}
$ 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")
}
host := os.Getenv("MYSQL_HOST")
database := os.Getenv("MYSQL_DATABASE")
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
if env != "testing" && host != "" {
config.Db.Host = host
}
if env != "testing" && database != "" {
config.Db.Database = database
}
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
}
$ docker compose exec api go generate ./ent
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/versioned-migration ./schema
$ 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_HOST=db
MYSQL_DATABASE=echo-db
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=
※FIREBASE_PRIVATE_KEYの値はシングルクォートで囲って設定して下さい。
次にコンフィグ設定を以下のように修正します。
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")
}
host := os.Getenv("MYSQL_HOST")
database := os.Getenv("MYSQL_DATABASE")
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
if env != "testing" && host != "" {
config.Db.Host = host
}
if env != "testing" && database != "" {
config.Db.Database = database
}
// 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": "%s",
"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(),
}
}
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),
}
}
次に以下のコマンドを実行し、マイグレーションファイルを作成します。
$ 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も試してみましたが、これらはまだまだ情報が少なくて使い方を理解するのに苦労したので、興味がある方はぜひ参考にしてみて下さい。
コメント