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

Go言語(Golang)のEchoでシンプルかつ実務的なバックエンドAPI開発方法まとめ
では早速ですが、まずは以下のコマンド実行し、各種ファイルを作成します。
$ mkdir go-echo-v2 && cd go-echo-v2
$ mkdir -p docker/local/go && cd docker/local/go && touch Dockerfile && cd ../../..
$ mkdir src && cd src
$ mkdir -p util/context && cd util/context && touch context.go && cd ../..
$ mkdir -p util/logger && cd util/logger && touch logger.go && cd ../..
$ mkdir middleware && cd middleware && touch middleware.go && cd ..
$ mkdir -p internal/repositories/index && cd internal/repositories/index && touch index.go && cd ../../..
$ mkdir -p internal/services/index && cd internal/services/index && touch index.go && cd ../../..
$ mkdir -p internal/handlers/index && cd internal/handlers/index && touch index.go index_test.go && cd ../../..
$ mkdir router && cd router && touch router.go && cd ..
$ touch .env main.go
$ cd .. && touch compose.yml
次に作成したファイルをそれぞれ以下のように記述します。
・「docker/local/go/Dockerfile」
FROM golang:1.24-alpine3.21
WORKDIR /go/src
COPY ./src .
# インストール可能なパッケージ一覧の更新
RUN apk update && \
apk upgrade && \
# パッケージのインストール(--no-cacheでキャッシュ削除)
apk add --no-cache \
git \
curl
# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
go install; \
fi
# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@latest
RUN go install honnef.co/go/tools/cmd/staticcheck@latest
RUN go install go.uber.org/mock/mockgen@latest
RUN go install github.com/swaggo/swag/cmd/swag@latest
# マイグレーション用のatlasをインストール
RUN curl -sSf https://atlasgo.sh | sh
EXPOSE 8080
※goのバージョンは1.24.xです。
・「src/util/context/context.go」
package context
import (
"context"
"github.com/labstack/echo/v4"
)
type contextKey string
const (
XRequestId contextKey = "X-Request-Id"
XRequestSource contextKey = "X-Request-Source"
XUid contextKey = "X-Uid"
)
func CreateContext(c echo.Context) context.Context {
req := c.Request()
requestId := req.Header.Get("X-Request-Id")
if requestId == "" {
requestId = "-"
}
requestSource := req.Header.Get("X-Request-Source")
if requestSource == "" {
requestSource = "-"
}
uid := req.Header.Get("X-Uid")
if uid == "" {
uid = "-"
}
// コンテキストの設定
ctx := context.WithValue(req.Context(), XRequestId, requestId)
ctx = context.WithValue(ctx, XRequestSource, requestSource)
ctx = context.WithValue(ctx, XUid, uid)
return ctx
}
※これはログ出力のためのコンテキスト作成用関数です。(一意のIDなどをログに付けたい)
・「src/util/logger/logger.go」
package logger
import (
"context"
"log/slog"
"os"
utilContext "go-echo-v2/util/context"
)
type SlogHandler struct {
slog.Handler
}
func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
requestId, ok := ctx.Value(utilContext.XRequestId).(string)
if ok {
r.AddAttrs(slog.Attr{Key: "requestId", Value: slog.String("requestId", requestId).Value})
}
requestSource, ok := ctx.Value(utilContext.XRequestSource).(string)
if ok {
r.AddAttrs(slog.Attr{Key: "requestSource", Value: slog.String("requestSource", requestSource).Value})
}
uid, ok := ctx.Value(utilContext.XUid).(string)
if ok {
r.AddAttrs(slog.Attr{Key: "uid", Value: slog.String("uid", uid).Value})
}
return h.Handler.Handle(ctx, r)
}
var slogHandler = &SlogHandler{
slog.NewTextHandler(os.Stdout, nil),
}
var logger = slog.New(slogHandler)
func Info(ctx context.Context, message string) {
env := os.Getenv("ENV")
if env != "testing" {
logger.InfoContext(ctx, message)
}
}
func Warn(ctx context.Context, message string) {
env := os.Getenv("ENV")
if env != "testing" {
logger.WarnContext(ctx, message)
}
}
func Error(ctx context.Context, message string) {
env := os.Getenv("ENV")
if env != "testing" {
logger.ErrorContext(ctx, message)
}
}
func LogAttrs(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
env := os.Getenv("ENV")
if env != "testing" {
logger.LogAttrs(ctx, level, msg, attrs...)
}
}
・「src/middleware/middleware.go」
package middleware
import (
"log/slog"
"net/http"
"strings"
utilContext "go-echo-v2/util/context"
"go-echo-v2/util/logger"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
)
// リクエスト用のミドルウェア
func RequestMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
req := c.Request()
requestId := uuid.New().String()
req.Header.Set("X-Request-Id", requestId)
return next(c)
}
}
// ロガー用のミドルウェア(リクエスト単位でログ出力)
func LoggerMiddleware() echo.MiddlewareFunc {
return echoMiddleware.RequestLoggerWithConfig(echoMiddleware.RequestLoggerConfig{
LogRemoteIP: true,
LogUserAgent: true,
LogMethod: true,
LogURI: true,
LogStatus: true,
LogLatency: true,
LogError: true,
HandleError: true,
LogValuesFunc: func(c echo.Context, v echoMiddleware.RequestLoggerValues) error {
ctx := utilContext.CreateContext(c)
// ログレベルの設定
level := slog.LevelInfo
if v.Status >= http.StatusBadRequest && v.Status < http.StatusInternalServerError {
level = slog.LevelWarn
} else if v.Status >= http.StatusInternalServerError {
level = slog.LevelError
}
// ログ出力設定
attrs := []slog.Attr{
slog.String("remote_ip", v.RemoteIP),
slog.String("user_agent", v.UserAgent),
slog.String("method", v.Method),
slog.String("uri", v.URI),
slog.Int("status", v.Status),
slog.String("latency", v.Latency.String()),
}
// エラーが発生している場合
if v.Error != nil {
if httpError, ok := v.Error.(*echo.HTTPError); ok {
if msg, ok := httpError.Message.(string); ok {
attrs = append(attrs, slog.String("err", msg))
} else {
attrs = append(attrs, slog.String("err", v.Error.Error()))
}
} else {
attrs = append(attrs, slog.String("err", v.Error.Error()))
}
} else {
attrs = append(attrs, slog.String("err", "-"))
}
// ログ出力
logger.LogAttrs(ctx, level, "REQUEST", attrs...)
return nil
},
})
}
// CORS設定用のミドルウェア
func CorsMiddleware() echo.MiddlewareFunc {
return echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Request-Source"},
})
}
// 認証用のミドルウェア
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Authorizationヘッダーからトークンを取得
idToken := ""
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
idToken = strings.Replace(authHeader, "Bearer ", "", 1)
} else {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
// TODO: 必要な認証処理を実装する
_ = idToken
return next(c)
}
}
※これは各種ミドルウェア用のファイルです。必要に応じて内容を修正して下さい。
・「src/internal/repositories/index/index.go」
package index
import (
"os"
)
// インターフェース定義
type IndexRepository interface {
Hello() string
}
// 構造体定義
type indexRepository struct{}
// インスタンス生成用関数
func NewIndexRepository() IndexRepository {
return &indexRepository{}
}
// メソッド定義
func (r *indexRepository) Hello() string {
env := os.Getenv("ENV")
res := "Hello World !!"
if env == "testing" {
res = "Testing Hello World !!"
}
return res
}
※これは例えばDBなどの操作を関数に切り出すためのリポジトリファイルで、テストコードでモック化しやすくするため、インターフェースを使った記述方法で定義しています。
・「src/internal/services/index/index.go」
package index
import (
"net/http"
"go-echo-v2/internal/repositories/index"
"github.com/labstack/echo/v4"
)
// インターフェース定義
type IndexService interface {
Index(c echo.Context) error
}
// 構造体定義
type indexService struct {
indexRepository index.IndexRepository
}
// インスタンス生成用関数
func NewIndexService(
indexRepository index.IndexRepository,
) IndexService {
return &indexService{
indexRepository: indexRepository,
}
}
func (s *indexService) Index(c echo.Context) error {
text := s.indexRepository.Hello()
return c.String(http.StatusOK, text)
}
※これは業務ロジックなどの処理を書くためのサービスファイルです。これもモック化しやすくするためにインターフェースを使った記述方法で定義しています。
・「src/internal/handlers/index/index.go」
package index
import (
repoIndex "go-echo-v2/internal/repositories/index"
"go-echo-v2/internal/services/index"
"github.com/labstack/echo/v4"
)
// @Description テキスト「Hello World !!」を出力する。
// @Tags index
// @Success 200
// @Router /api/v1/ [get]
func Index(c echo.Context) error {
// インスタンス生成
indexRepository := repoIndex.NewIndexRepository()
indexService := index.NewIndexService(indexRepository)
// サービス実行
return indexService.Index(c)
}
※これはルーティングから呼ばれるハンドラーファイルです。処理はシンプルにサービスを呼び出すだけです。OpenAPIの仕様書も作成できるようにしているため、その内容をコメント部分で定義しています。
package index
import (
"testing"
"log/slog"
"os"
"net/http"
"net/http/httptest"
"encoding/json"
mockRepoIndex "go-echo-v2/internal/repositories/index/mock_index"
"go-echo-v2/internal/services/index"
"go-echo-v2/middleware"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestIndex(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
e := echo.New()
// テスト用リクエストのecho.Context作成
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// リポジトリのモック化
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockIndexRepository := mockRepoIndex.NewMockIndexRepository(ctrl)
mockIndexRepository.EXPECT().Hello().Return("Hello World !!")
indexService := index.NewIndexService(mockIndexRepository)
// テスト実行
err := indexService.Index(c)
// 検証
assert.Equal(t, nil, err)
assert.Equal(t, "Hello World !!", rec.Body.String())
}
// 認証用ミドルウェアのテスト
func TestAuthMiddleware(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
e := echo.New()
// ミドルウェアの適用
v1 := e.Group("/api/v1")
v1.GET("/", Index, middleware.AuthMiddleware)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/", nil)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果のJSONを取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
expected := map[string]interface{}{
"message": "Unauthorized",
}
// 検証
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, expected, resbody)
}
※これはテストコード用のファイルです。ハンドラーファイル単位で作成していく想定です。リポジトリファイルなどの部分はモック化してテスト可能です。
package router
import (
"go-echo-v2/internal/handlers/index"
"go-echo-v2/middleware"
"github.com/labstack/echo/v4"
)
func SetupRouter(e *echo.Echo) {
e.GET("/", index.Index)
v1 := e.Group("/api/v1")
v1.GET("/", index.Index, middleware.AuthMiddleware)
}
・「src/.env」
ENV=local
PORT=8080
POSTGRES_HOST=pg-db
POSTGRES_PORT=5432
POSTGRES_DB=pg-db
POSTGRES_USER=pg-user
POSTGRES_PASSWORD=pg-password
※これは環境変数を設定するためのファイルです。ローカル開発環境用なので機密情報を設定してもいいですが、もし本番環境を構築する際は機密情報は設定しないようにして下さい。(機密情報はインフラにあるサービスなどを利用してセキュリティに考慮すること。)
・「src/main.go」
package main
import (
"fmt"
"log/slog"
"os"
_ "go-echo-v2/docs"
"go-echo-v2/middleware"
"go-echo-v2/router"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
echoSwagger "github.com/swaggo/echo-swagger"
)
// @title go-echo-v2 API
// @version 1.0
// @description Go言語(Golang)のフレームワーク「Echo」によるAPI開発サンプルのバージョン2
// @host localhost:8080
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and token.
func main() {
// .env ファイルの読み込み
err := godotenv.Load()
if err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
e := echo.New()
// ミドルウェアの設定
e.Use(middleware.RequestMiddleware)
e.Use(middleware.LoggerMiddleware())
e.Use(middleware.CorsMiddleware())
e.Use(echoMiddleware.Recover()) // panic発生時にサーバー停止を防ぐ
// ルーティング設定
router.SetupRouter(e)
// API仕様書の設定
env := os.Getenv("ENV")
if env != "production" {
e.GET("/swagger/*", echoSwagger.WrapHandler)
}
// ポート番号の設定
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
startPort := fmt.Sprintf(":%s", port)
// サーバー起動
e.Logger.Fatal(e.Start(startPort))
}
※これはGoのサーバー起動用のメインファイルです。コメント部分ではOpenAPIの仕様書用の定義を記述しています。
・「compose.yml」
services:
api:
container_name: echo-api-v2
build:
context: .
dockerfile: ./docker/local/go/Dockerfile
command: air -c .air.toml
volumes:
- ./src:/go/src
ports:
- "8080:8080"
tty: true
stdin_open: true
※これはローカル開発環境を構築するためのdocker-composeファイルです。
次に以下のコマンドを実行し、初期化からローカルサーバーの起動まで行います。
$ docker compose build --no-cache
$ docker compose run --rm api go mod init go-echo-v2
$ docker compose run --rm api go mod tidy
$ docker compose run --rm api air init
$ docker compose run --rm api swag i
$ docker compose run --rm api mockgen -source=./internal/repositories/index/index.go -destination=./internal/repositories/index/mock_index/mock_index.go
$ docker compose up -d
ローカルサーバー起動後、ブラウザで「http://localhost:8080」を開き、下図のようにテキストが出力されればOKです。
そしてブラウザで「http://localhost:8080/swagger/index.html」を開き、下図のようにOpenAPIのAPI仕様書が表示されればOKです。
※今回はやりませんでしたが、出力されたファイルをnpmライブラリ(node.jsを使う)などでmd形式のファイルに変換させると、GitHubでも直接参照できる形で管理可能です。
また、API「/api/v1/」については認証処理をするようにしているため、Bearerトークンを付けずに実行し、下図のように401エラーになればOKです。
そしてBearerトークンを付けて再度実行し、下図のように正常終了すればOKです。
また、ロガー用のミドルウェアも設定しているため、APIなどを実行後に以下のコマンドを実行してログ出力を確認してみて下さい。
$ docker compose logs
コマンド実行後、以下のようにリクエスト単位でアクセスログが出力されていればOKです。
※ログ出力をさせたい場合はファイル「/util/logger」の共通ロガー用関数を使うことで、一意のリクエストIDが付与されたログ出力が可能で、障害発生時のログ調査もしやすくなります。
各種コマンド一覧
そのほか、コードを修正した際に使える各種コマンドは以下の通りです。
※ローカルサーバー起動中に実行可能です。
・Go標準パッケージによるフォーマット修正
$ docker compose exec api go fmt ./...
※チーム開発ならフォーマットを統一するのは大事になります。
・ライブラリ「staticcheck」によるコード解析チェック
$ docker compose exec api staticcheck ./...
※コード修正後は必ず実行し、警告やエラーがあったら解消して下さい。
・テストコードの実行
$ docker compose exec api go test -v ./internal/handlers/...
※実務でテストコードも作るのは優先度が低い可能性がありますが、本番リリースして運用後などにリファクタリングなどが必要になった場合、テストコードが無いと詰む可能性があります。どこかのタイミングで必ず作っておいた方がいいです。(テストコードがあれば100%安全というわけではありませんが、最低限の動作保証がされます。)
・モック用ファイル作成コマンド(仮)
$ docker compose exec api mockgen -source=./internal/repositories/index/index.go -destination=./internal/repositories/index/mock_index/mock_index.go
※主にリポジトリファイルのモック用ファイルを作るために使います。コマンド利用時はファイルパスやファイル名は修正して下さい。(インターフェースで定義しているのをモック化できます。)
・OpenAPIの仕様書修正
$ docker compose exec api swag i
※コードからドキュメントを作成・修正できるので、最初から実装しておくと便利です。実務ではこのようなドキュメント作成も優先度が低いと思いますが、例えば上場を目指すような企業であれば、ドキュメントをちゃんと管理しているのは非常に大事なので、バックエンドAPIなら最低限でもこのようなOpenAPIの仕様書は作成しておいた方がいいです。
ent.とAtlasによるデータベース設定の追加
次にデータベースを利用できるようにするため、以下のコマンドを実行して各種ファイルを作成しますが、今回はDBにPostgreSQL、ORMにent.、マイグレーションの管理にAtlasを使います。
$ mkdir -p docker/local/db && cd docker/local/db && touch Dockerfile
$ mkdir init && cd init && touch init.sql && cd ../../../..
$ mkdir -p src/database && cd src/database && touch database.go
$ mkdir -p cmd/migrate/apply && cd cmd/migrate/apply && touch main.go && cd ..
$ mkdir status && cd status && touch main.go && cd ..
$ mkdir down && cd down && touch main.go && cd ../..
$ mkdir -p seeder/user && cd seeder/user && touch main.go && cd ../../../../..
次に作成した各種ファイルについて、それぞれ以下のように記述します。
・「src/docker/local/db/Dockerfile」
FROM postgres:17.3
ENV LANG ja_JP.utf8
# PostgreSQLの日本語化で「ja_JP.utf8」を使うために必要
RUN apt-get update && \
apt-get install -y locales && \
rm -rf /var/lib/apt/lists/* && \
localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
※PostgreSQLを日本語化するのに個別のDorckerfileが必要でした。
・「src/docker/local/db/init/init.sql」
-- テストユーザの作成
CREATE USER testuser;
ALTER USER testuser WITH PASSWORD 'test-password';
-- テストDBの作成
CREATE DATABASE testdb;
-- Atlas用のDB作成
CREATE DATABASE tmpdb;
-- テストユーザーにDBの接続権限付与
GRANT CONNECT ON DATABASE testdb TO testuser;
GRANT CONNECT ON DATABASE tmpdb TO testuser;
-- -- テストDBの権限付与
\c testdb
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO testuser;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO testuser;
GRANT USAGE ON SCHEMA public TO testuser;
GRANT CREATE ON SCHEMA public TO testuser;
-- -- Atlas用のDBの権限付与
\c tmpdb
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO testuser;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO testuser;
GRANT USAGE ON SCHEMA public TO testuser;
GRANT CREATE ON SCHEMA public TO testuser;
※docker-composeでDBコンテナを起動させた際に、テスト用のDBおよびAtlasコマンド実行時に必要になる空のDB設定を追加するSQLです。
・「src/database/database.go」
package database
import (
"context"
"fmt"
"os"
"go-echo-v2/ent"
"entgo.io/ent/dialect"
_ "github.com/lib/pq" // DB driver
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func CreateDsnForEnt() string {
env := os.Getenv("ENV")
// DBの接続情報設定
var dsn string
if env == "testing" {
dsn = fmt.Sprintf(
"host=%s port=%s user=%s dbname=%s password=%s search_path=public sslmode=disable",
"pg-db",
"5432",
"testuser",
"testdb",
"test-password",
)
} else {
host := os.Getenv("POSTGRES_HOST")
port := os.Getenv("POSTGRES_PORT")
user := os.Getenv("POSTGRES_USER")
db := os.Getenv("POSTGRES_DB")
pass := os.Getenv("POSTGRES_PASSWORD")
if env == "production" {
dsn = fmt.Sprintf(
"host=%s port=%s user=%s dbname=%s password=%s search_path=public",
host,
port,
user,
db,
pass,
)
} else {
dsn = fmt.Sprintf(
"host=%s port=%s user=%s dbname=%s password=%s search_path=public sslmode=disable",
host,
port,
user,
db,
pass,
)
}
}
return dsn
}
func CreateDsnForAtlas() string {
env := os.Getenv("ENV")
// DBの接続情報設定
var dsn string
if env == "testing" {
dsn = fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?search_path=public&sslmode=disable",
"testuser",
"test-password",
"pg-db",
"5432",
"testdb",
)
} else {
host := os.Getenv("POSTGRES_HOST")
port := os.Getenv("POSTGRES_PORT")
user := os.Getenv("POSTGRES_USER")
db := os.Getenv("POSTGRES_DB")
pass := os.Getenv("POSTGRES_PASSWORD")
if env == "production" {
dsn = fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?search_path=public",
user,
pass,
host,
port,
db,
)
} else {
dsn = fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?search_path=public&sslmode=disable",
user,
pass,
host,
port,
db,
)
}
}
return dsn
}
func SetupDatabase(ctx context.Context) (*ent.Client, error) {
// DBの接続情報の取得
dsn := CreateDsnForEnt()
// DBクライアントの取得
var client *ent.Client
client, err := ent.Open(dialect.Postgres, dsn)
if err != nil {
return nil, err
}
return client, nil
}
// GORMで接続したい場合
func SetupDatabaseWithGorm(ctx context.Context) (*gorm.DB, error) {
// DBの接続情報の取得
dsn := CreateDsnForAtlas()
// DBクライアントの取得
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}
※DB接続確認をしたい場合にent.ではできないため、GORMで接続するための設定も記述しています。
・「src/database/cmd/migrate/apply/main.go」
package main
import (
"context"
"fmt"
"os/exec"
"go-echo-v2/database"
"go-echo-v2/util/logger"
"github.com/joho/godotenv"
)
func main() {
ctx := context.Background()
// .env ファイルの読み込み
err := godotenv.Load()
if err != nil {
logger.Error(ctx, ".envファイルの読み込みに失敗しました。")
}
// DBの接続情報取得
dsn := database.CreateDsnForAtlas()
// コマンド生成
cmd := exec.Command("atlas", "migrate", "apply", "--dir", "file://ent/migrate/migrations", "--url", dsn)
// コマンド実行
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("コマンド実行で失敗しました。:%s\n", err.Error())
logger.Error(ctx, msg)
}
// ログ出力
fmt.Println(string(out))
}
※これはAtlasコマンドでマイグレーションを実行するためのスクリプトファイルです。
・「src/database/cmd/migrate/status/main.go」
package main
import (
"context"
"fmt"
"os/exec"
"go-echo-v2/database"
"go-echo-v2/util/logger"
"github.com/joho/godotenv"
)
func main() {
ctx := context.Background()
// .env ファイルの読み込み
err := godotenv.Load()
if err != nil {
logger.Error(ctx, ".envファイルの読み込みに失敗しました。")
}
// DBの接続情報取得
dsn := database.CreateDsnForAtlas()
// コマンド生成
cmd := exec.Command("atlas", "migrate", "status", "--dir", "file://ent/migrate/migrations", "--url", dsn)
// コマンド実行
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("コマンド実行で失敗しました。:%s\n", err.Error())
logger.Error(ctx, msg)
}
// ログ出力
fmt.Println(string(out))
}
※これはAtlasコマンドでマイグレーションの状態を確認するためのスクリプトファイルです。
・「src/database/cmd/migrate/down/main.go」
package main
import (
"context"
"fmt"
"os/exec"
"go-echo-v2/database"
"go-echo-v2/util/logger"
"github.com/joho/godotenv"
)
func main() {
ctx := context.Background()
// .env ファイルの読み込み
err := godotenv.Load()
if err != nil {
logger.Error(ctx, ".envファイルの読み込みに失敗しました。")
}
// DBの接続情報取得
dsn := database.CreateDsnForAtlas()
// 一時的に使うDB接続情報
devDsn := "postgres://testuser:test-password@pg-db:5432/tmpdb?search_path=public&sslmode=disable"
// コマンド生成
cmd := exec.Command("atlas", "migrate", "down", "--dir", "file://ent/migrate/migrations", "--url", dsn, "--dev-url", devDsn)
// コマンド実行
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("コマンド実行で失敗しました。:%s\n", err.Error())
logger.Error(ctx, msg)
}
// ログ出力
fmt.Println(string(out))
}
※これはAtlasコマンドで既に反映したマイグレーションを一つ前に戻すためのスクリプトファイルです。(–dev-urlの部分で空のDBへの接続が必要になります。)
・「src/database/cmd/seeder/user/main.go」
package main
import (
"context"
"fmt"
"go-echo-v2/database"
"go-echo-v2/util/logger"
"github.com/brianvoe/gofakeit/v7"
"github.com/google/uuid"
"github.com/joho/godotenv"
)
func main() {
ctx := context.Background()
// .env ファイルの読み込み
err := godotenv.Load()
if err != nil {
logger.Error(ctx, ".envファイルの読み込みに失敗しました。")
}
// DBに接続してクライアント取得
client, err := database.SetupDatabase(ctx)
if err != nil {
msg := fmt.Sprintf("DB接続に失敗しました。: %v", err)
logger.Error(ctx, msg)
}
defer client.Close()
// ユーザー登録
uid := uuid.New().String()
lastName := gofakeit.LastName()
firstName := gofakeit.FirstName()
email := gofakeit.Email()
user, err := client.User.Create().
SetUID(uid).
SetLastName(lastName).
SetFirstName(firstName).
SetEmail(email).
Save(ctx)
if err != nil {
msg := fmt.Sprintf("ユーザー登録に失敗しました。: %v", err)
logger.Error(ctx, msg)
}
// 登録したユーザーをログ出力
logger.Info(ctx, user.String())
}
※これはent.を使ってユーザーデータを1件登録することができるSeederファイルです。登録値はgofakeitを使って実行するたびに変わるようにしています。
次に「compose.yml」を以下のように修正します。
services:
api:
container_name: echo-api-v2
build:
context: .
dockerfile: ./docker/local/go/Dockerfile
command: air -c .air.toml
volumes:
- ./src:/go/src
ports:
- "8080:8080"
tty: true
stdin_open: true
depends_on:
- pg-db
pg-db:
container_name: echo-db-v2
build:
context: .
dockerfile: ./docker/local/db/Dockerfile
environment:
- POSTGRES_DB=pg-db
- POSTGRES_USER=pg-user
- POSTGRES_PASSWORD=pg-password
- POSTGRES_INITDB_ARGS=--locale=ja_JP.utf8
- TZ=Asia/Tokyo
ports:
- "5432:5432"
volumes:
- ./docker/local/db/init:/docker-entrypoint-initdb.d
- pg-db-data:/var/lib/postgresql/data
volumes:
pg-db-data:
driver: local
※DB関連の設定を追加して下さい。
次に以下のコマンドを実行し、コンテナの再ビルドおよび再起動を行います。
$ docker compose exec api go modi tidy
$ docker compose down -v
$ docker compose build --no-cache
$ docker compose up -d
次に以下のコマンドを実行し、DBに接続できることを確認します。
$ docker compose exec pg-db bash
$ PGPASSWORD=pg-password psql -U pg-user pg-db
コマンド実行後、以下のように接続できればOKです。
※終了したい場合は、「exit」コマンドを2回実行すると戻れます。
ユーザーテーブル用のスキーマ作成およびent.の初期化
次に以下のコマンドを実行し、ユーザーテーブル用のスキーマファイルを作成します。
$ docker compose exec api go run -mod=mod entgo.io/ent/cmd/ent new User
次に作成されたファイル「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{
"postgres": "timestamptz",
}).
Default(time.Now).
Immutable(),
field.Time("updated_at").
SchemaType(map[string]string{
"postgres": "timestamptz",
}).
Default(time.Now).
UpdateDefault(time.Now),
field.Time("deleted_at").
SchemaType(map[string]string{
"postgres": "timestamptz",
}).
Nillable().
Optional(),
}
}
func (User) Indexes() []ent.Index {
return []ent.Index{
index.Fields("deleted_at"),
}
}
func (User) Edges() []ent.Edge {
return nil
}
次に以下のコマンドを実行し、ent.の初期化を行います。
$ docker compose exec api go generate ./ent
$ docker compose exec api go mod tidy
次に以下のコマンドを実行し、Atlasコマンドでマイグレーション用のファイルを作成します。
$ docker compose exec api atlas migrate diff add_users \
$ --dir "file://ent/migrate/migrations" \
$ --to "ent://ent/schema" \
$ --dev-url "postgres://pg-user:pg-password@pg-db:5432/pg-db?search_path=public&sslmode=disable"
コマンド実行後、ディレクトリ「src/ent/migrate/migrations」の直下にマイグレーション用のファイル「XXXXXXXXXXXXXX_add_users.sql」が作成されていればOKです。
次に以下のコマンドを実行し、マイグレーションの状態を確認します。
$ docker compose exec api atlas migrate status \
$ --dir "file://ent/migrate/migrations" \
$ --url "postgres://pg-user:pg-password@pg-db:5432/pg-db?search_path=public&sslmode=disable"
まだマイグレーションは一度も実行してないため、コマンドを実行して状態を確認するとMigration Statusが「PENDING」になっています。
次に以下のコマンドを実行し、マイグレーションを実行します。
$ docker compose exec api atlas migrate apply \
$ --dir "file://ent/migrate/migrations" \
$ --url "postgres://pg-user:pg-password@pg-db:5432/pg-db?search_path=public&sslmode=disable"
コマンド実行後、以下のようになればOKです。
もう一度マイグレーションの状態を確認し、Migration Statusが「OK」になっていればOKです。
もし反映したマイグレーションをロールバックして戻したい場合は、以下のコマンドを実行するとできます。
$ docker compose exec api atlas migrate down \
$ --dir "file://ent/migrate/migrations" \
$ --url "postgres://pg-user:pg-password@pg-db:5432/pg-db?search_path=public&sslmode=disable" \
$ --dev-url "postgres://testuser:test-password@pg-db:5432/tmpdb?search_path=public&sslmode=disable"
※–dev-urlには空のDBへの接続情報を設定する必要があります。
また、テストコードでテスト用のDBを使う場合、以下のコマンドでテスト用のDBにもマイグレーションを実行して下さい。
$ docker compose exec api atlas migrate apply \
$ --dir "file://ent/migrate/migrations" \
$ --url "postgres://testuser:test-password@pg-db:5432/testdb?search_path=public&sslmode=disable"
尚、上記で実行しているコマンドについて、ディレクトリ「src/database/cmd/migrate」内にある各種goのファイルでスクリプト化しているため、以下のコマンドでも実行できます。
・ステータス確認用コマンド
$ docker compose exec api go run ./database/cmd/migrate/status/main.go
$ docker compose exec -e ENV=testing api go run ./database/cmd/migrate/status/main.go
※二つ目はテスト用のDBに対して実行するコマンドで、ENVに「testing」を指定することで切り替えられるようにしています。
・マイグレーション用コマンド
$ docker compose exec api go run ./database/cmd/migrate/apply/main.go
$ docker compose exec -e ENV=testing api go run ./database/cmd/migrate/apply/main.go
・ロールバック用コマンド
$ docker compose exec api go run ./database/cmd/migrate/down/main.go
$ docker compose exec -e ENV=testing api go run ./database/cmd/migrate/down/main.go
次に以下のコマンドを実行し、Seederファイルを使ってユーザーデータを1件登録してみます。
$ docker compose exec api go run ./database/cmd/seeder/user/main.go
コマンド実行後、以下のように登録したデータがログ出力されていればOKです。
次に以下のコマンドを実行し、DBに接続して登録したデータを確認してみて下さい。
$ docker compose exec pg-db bash
$ PGPASSWORD=pg-password psql -U pg-user pg-db
$ select * from users;
コマンド実行後、以下のようにユーザーデータが登録されていればOKです。
※元の画面に戻りたい場合はキーボードの「q」を押して下さい。さらにDB接続から抜けたい場合は「exit」を2回実行して下さい。
尚、DBのデータはDockerのボリュームに保存するようにしたので、ボリュームを消さない限りは保持されます。
もしボリュームを消したい場合、dockerコマンドで個別に消去するか、以下のようにコンテナをダウンするコマンドにオプション「-v」を付けるとボリュームも一緒に削除可能です。
$ docker compose down -v
APIキー認証付きのヘルスチェックAPIを作る
次にAPIおよびDBとの接続確認をできるようにするため、API認証付きのヘルスチェックAPIを作ります。
まずは以下のコマンドを実行し、APIキーとパスワード生成用のファイルを作成します。
$ mkdir -p src/cmd/create-apikey && cd src/cmd/create-apikey
$ touch main.go && cd ../../..
次に作成したファイルを以下のように記述します。
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
func generatePassword(length int) (string, error) {
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func main() {
password, err := generatePassword(25)
if err != nil {
fmt.Println("パスワードの生成に失敗しました!")
return
}
// パスワードをSHA-256でハッシュ化
hash := sha256.Sum256([]byte(password))
hashString := hex.EncodeToString(hash[:])
// ログ出力
fmt.Println("APIキーとそのパスワードを生成しました!")
fmt.Println("APIキー:", hashString)
fmt.Println("パスワード:", password)
}
$ docker compose exec api go mod tidy
$ docker compose exec api go run ./cmd/create-apikey/main.go
コマンド実行後、以下のようなログ出力が表示されればOKです。
次に生成したAPIキーを環境変数用のファイル「.env」に環境変数「GO_ECHO_V2_API_KEY」として追加します。
・「src/.env」
ENV=local
PORT=8080
POSTGRES_HOST=pg-db
POSTGRES_PORT=5432
POSTGRES_DB=pg-db
POSTGRES_USER=pg-user
POSTGRES_PASSWORD=pg-password
GO_ECHO_V2_API_KEY=208c190373d51328cfda7b27993925bcc4c5edd0b50593f0a23cb730493f4711
次に環境変数を再読み込みするため、以下のコマンドを実行してdockerコンテナを再起動します。
$ docker compose down
$ docker compose up -d
次にミドルウェア用のファイルにAPIキー認証用のミドルウェアを追加します。
・「src/middleware/middleware.go」
package middleware
import (
"log/slog"
"net/http"
"strings"
"os"
"crypto/sha256"
"encoding/hex"
utilContext "go-echo-v2/util/context"
"go-echo-v2/util/logger"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
)
// リクエスト用のミドルウェア
func RequestMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
req := c.Request()
requestId := uuid.New().String()
req.Header.Set("X-Request-Id", requestId)
return next(c)
}
}
// ロガー用のミドルウェア(リクエスト単位でログ出力)
func LoggerMiddleware() echo.MiddlewareFunc {
return echoMiddleware.RequestLoggerWithConfig(echoMiddleware.RequestLoggerConfig{
LogRemoteIP: true,
LogUserAgent: true,
LogMethod: true,
LogURI: true,
LogStatus: true,
LogLatency: true,
LogError: true,
HandleError: true,
LogValuesFunc: func(c echo.Context, v echoMiddleware.RequestLoggerValues) error {
ctx := utilContext.CreateContext(c)
// ログレベルの設定
level := slog.LevelInfo
if v.Status >= http.StatusBadRequest && v.Status < http.StatusInternalServerError {
level = slog.LevelWarn
} else if v.Status >= http.StatusInternalServerError {
level = slog.LevelError
}
// ログ出力設定
attrs := []slog.Attr{
slog.String("remote_ip", v.RemoteIP),
slog.String("user_agent", v.UserAgent),
slog.String("method", v.Method),
slog.String("uri", v.URI),
slog.Int("status", v.Status),
slog.String("latency", v.Latency.String()),
}
// エラーが発生している場合
if v.Error != nil {
if httpError, ok := v.Error.(*echo.HTTPError); ok {
if msg, ok := httpError.Message.(string); ok {
attrs = append(attrs, slog.String("err", msg))
} else {
attrs = append(attrs, slog.String("err", v.Error.Error()))
}
} else {
attrs = append(attrs, slog.String("err", v.Error.Error()))
}
} else {
attrs = append(attrs, slog.String("err", "-"))
}
// ログ出力
logger.LogAttrs(ctx, level, "REQUEST", attrs...)
return nil
},
})
}
// CORS設定用のミドルウェア
func CorsMiddleware() echo.MiddlewareFunc {
return echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Request-Source"},
})
}
// 認証用のミドルウェア
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Authorizationヘッダーからトークンを取得
idToken := ""
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
idToken = strings.Replace(authHeader, "Bearer ", "", 1)
} else {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
// TODO: 必要な認証処理を実装する
_ = idToken
return next(c)
}
}
// APIキー認証用のミドルウェア
func ApiKeyAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Authorizationへっだーからトークンを取得
idToken := ""
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
idToken = strings.Replace(authHeader, "Bearer ", "", 1)
} else {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
// APIキー認証チェック
apiKey := os.Getenv("GO_ECHO_V2_API_KEY")
hash := sha256.Sum256([]byte(idToken))
hashString := hex.EncodeToString(hash[:])
if hashString != apiKey {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
return next(c)
}
}
次に以下のコマンドを実行し、ヘルスチェックAPI用の各種ファイルを作成します。
$ mkdir -p src/internal/repositories/healthcheck && cd src/internal/repositories/healthcheck
$ touch healthcheck.go && cd ../../../..
$ mkdir -p src/internal/services/healthcheck && cd src/internal/services/healthcheck
$ touch healthcheck.go && cd ../../../..
$ mkdir -p src/internal/handlers/healthcheck cd src/internal/handlers/healthcheck
$ touch healthcheck.go healthcheck_test.go && cd ../../../..
次に作成したファイルをそれぞれ以下のように記述します。
・「src/internal/repositories/healthcheck/healthcheck.go」
package healthcheck
import (
"context"
"fmt"
"go-echo-v2/database"
)
// インターフェースの定義
type HealthcheckRepository interface {
Healthcheck(ctx context.Context) error
}
// 構造体の定義
type healthcheckRepository struct {}
// インスタンス生成用関数の定義
func NewHealthcheckRepository() HealthcheckRepository {
return &healthcheckRepository{}
}
// Healthcheckメソッドの実装
func (h *healthcheckRepository) Healthcheck(ctx context.Context) error {
db, err := database.SetupDatabaseWithGorm(ctx)
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
// DB接続確認
if err := sqlDB.Ping(); err != nil {
return fmt.Errorf("database check failed: %w", err)
}
return nil
}
・「src/internal/services/healthcheck/healthcheck.go」
package healthcheck
import (
"fmt"
"net/http"
"go-echo-v2/internal/repositories/healthcheck"
"go-echo-v2/util/logger"
utilContext "go-echo-v2/util/context"
"github.com/labstack/echo/v4"
)
// レスポンス結果の型定義
type OKResponse struct {
Message string `json:"message"`
}
// インターフェースの定義
type HealthcheckService interface {
Healthcheck(c echo.Context) error
}
// 構造体の定義
type healthcheckService struct {
healthcheckRepository healthcheck.HealthcheckRepository
}
// インスタンス生成用関数の定義
func NewHealthcheckService(
healthcheckRepository healthcheck.HealthcheckRepository,
) HealthcheckService {
return &healthcheckService{
healthcheckRepository: healthcheckRepository,
}
}
// Healthcheckメソッドの実装
func (s *healthcheckService) Healthcheck(c echo.Context) error {
ctx := utilContext.CreateContext(c)
err := s.healthcheckRepository.Healthcheck(ctx)
if err != nil {
msg := fmt.Sprintf("Failed to health check: %v", err)
logger.Error(ctx, msg)
return echo.NewHTTPError(http.StatusInternalServerError, msg)
}
res := OKResponse{
Message: "Health Check OK !!",
}
return c.JSON(http.StatusOK, res)
}
・「src/internal/handlers/healthcheck/healthcheck.go」
package healthcheck
import (
repoHealthcheck "go-echo-v2/internal/repositories/healthcheck"
"go-echo-v2/internal/services/healthcheck"
"github.com/labstack/echo/v4"
)
// OpenAPI仕様書用の型定義
type OKResponse struct {
Message string `json:"message" example:"Health Check OK !!"`
}
type InternalServerErrorResponse struct {
Message string `json:"message" example:"Failed to health check: error message"`
}
// @Description APIとDBの接続確認をするためのヘルスチェックAPI
// @Tags healthcheck
// @Security Bearer
// @Success 200 {object} OKResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/healthcheck [get]
func Healthcheck(c echo.Context) error {
// インスタンス生成
healthcheckRepository := repoHealthcheck.NewHealthcheckRepository()
healthcheckService := healthcheck.NewHealthcheckService(healthcheckRepository)
// サービス実行
return healthcheckService.Healthcheck(c)
}
・「src/internal/handlers/healthcheck/healthcheck_test.go」
package healthcheck
import (
"testing"
"log/slog"
"os"
"net/http"
"net/http/httptest"
"encoding/json"
"fmt"
mockRepoHealthcheck "go-echo-v2/internal/repositories/healthcheck/mock_healthcheck"
"go-echo-v2/internal/services/healthcheck"
"go-echo-v2/middleware"
utilContext "go-echo-v2/util/context"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestHealthCheckOK(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ミドルウェアの適用
e := echo.New()
v1 := e.Group("/api/v1")
v1.GET("/healthcheck", Healthcheck, middleware.ApiKeyAuthMiddleware)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/healthcheck", nil)
token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
bearerToken := "Bearer " + token
req.Header.Set("Authorization", bearerToken)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果のJSONを取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
expected := map[string]interface{}{
"message": "Health Check OK !!",
}
// 検証
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, expected, resbody)
}
func TestHealthCheckNotToken(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ミドルウェアの適用
e := echo.New()
v1 := e.Group("/api/v1")
v1.GET("/healthcheck", Healthcheck, middleware.ApiKeyAuthMiddleware)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/healthcheck", nil)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果のJSONを取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
expected := map[string]interface{}{
"message": "Unauthorized",
}
// 検証
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, expected, resbody)
}
func TestHealthCheckAuthError(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ミドルウェアの適用
e := echo.New()
v1 := e.Group("/api/v1")
v1.GET("/healthcheck", Healthcheck, middleware.ApiKeyAuthMiddleware)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/healthcheck", nil)
token := "xxxxxxxxxx"
bearerToken := "Bearer " + token
req.Header.Set("Authorization", bearerToken)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果のJSONを取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
expected := map[string]interface{}{
"message": "Unauthorized",
}
// 検証
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, expected, resbody)
}
func TestHealthCheckDBError(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
e := echo.New()
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/healthcheck", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
ctx := utilContext.CreateContext(c)
// リポジトリのモック化
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockHealthcheckRepository := mockRepoHealthcheck.NewMockHealthcheckRepository(ctrl)
mockHealthcheckRepository.EXPECT().Healthcheck(ctx).Return(echo.NewHTTPError(http.StatusInternalServerError, fmt.Errorf("database check failed: err")))
healthcheckService := healthcheck.NewHealthcheckService(mockHealthcheckRepository)
// テスト実行
err := healthcheckService.Healthcheck(c)
// 検証
assert.Error(t, err)
assert.Contains(t, err.Error(), "database check failed: err")
}
次にルーティング用のファイルを以下のように修正します。
package router
import (
"go-echo-v2/internal/handlers/index"
"go-echo-v2/internal/handlers/healthcheck"
"go-echo-v2/middleware"
"github.com/labstack/echo/v4"
)
func SetupRouter(e *echo.Echo) {
e.GET("/", index.Index)
v1 := e.Group("/api/v1")
v1.GET("/", index.Index, middleware.AuthMiddleware)
v1.GET("/healthcheck", healthcheck.Healthcheck, middleware.ApiKeyAuthMiddleware)
}
次に以下のコマンドを実行し、テストコード用のモックファイルを作成しておきます。
$ docker compose exec api mockgen -source=./internal/repositories/healthcheck/healthcheck.go -destination=./internal/repositories/healthcheck/mock_healthcheck/mock_healthcheck.go
次にcurlコマンドを使ってヘルスチェックAPIを実行してみます。
$ curl "http://localhost:8080/api/v1/healthcheck"
実行後、以下のようにエラーメッセージが出力されればOKです。
次にAPI認証をパスさせるためにリクエストヘッダーのAuthorizationに「Bearer 事前に生成したAPIキーのパスワード」を付与した以下のコマンドを実行し、再度確認します。
$ curl -H "Authorization: Bearer zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw" "http://localhost:8080/api/v1/healthcheck"
実行後、以下のように「Health Check OK !!」のメッセージが出力されればOKです。
尚、DBコンテナだけ停止させた状態で再度APIを実行すると、以下のようにエラーになることを確認できます。
ユーザーAPIのCRUD処理を作る
次にユーザーAPIのCRUD処理を作ってみます。まずは以下のコマンドを実行し、各種ファイルを作成します。
※事前に上記のマイグレーションをテストDBにも実行しておいて下さい。
$ mkdir -p src/util/validator && cd src/util/validator
$ toudh validator.go && cd ../../..
$ mkdir -p src/internal/repositories/user && cd src/internal/repositories/user
$ touch user.go && cd ../../../..
$ mkdir -p src/internal/services/user && cd src/internal/services/user
$ touch user.go && cd ../../../..
$ mkdir -p src/internal/handlers/user && cd src/internal/handlers/user
$ touch user.go user_1_common_test.go user_2_test.go user_3_test.go user_4_test.go user_5_test.go user_6_test.go
$ cd ../../../..
次に作成した各種ファイルを以下のように記述します。
・「src/util/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(),
}
}
・「src/internal/repositories/user/user.go」
package user
import (
"context"
"time"
"go-echo-v2/database"
"go-echo-v2/ent"
entUser "go-echo-v2/ent/user"
)
// インターフェースの定義
type UserRepository interface {
CreateUser(
ctx context.Context,
uid string,
firstName string,
lastName string,
email string,
) (*ent.User, error)
GetAllUsers(ctx context.Context) ([]*ent.User, error)
GetUserByUID(ctx context.Context, uid string) (*ent.User, error)
GetUserByEmail(ctx context.Context, email string) (*ent.User, error)
UpdateUserByUID(
ctx context.Context,
uid string,
lastName string,
firstName string,
email string,
) (*ent.User, error)
DeleteUserByUID(ctx context.Context, uid string) error
}
// 構造体の定義
type userRepository struct{}
// インスタンス生成用関数の定義
func NewUserRepository() UserRepository {
return &userRepository{}
}
// メソッドの実装
func (u *userRepository) CreateUser(
ctx context.Context,
uid string,
lastName string,
firstName string,
email string,
) (*ent.User, error) {
db, err := database.SetupDatabase(ctx)
if err != nil {
return nil, err
}
defer db.Close()
user, err := db.User.Create().
SetUID(uid).
SetLastName(lastName).
SetFirstName(firstName).
SetEmail(email).
Save(ctx)
if err != nil {
return nil, err
}
return user, nil
}
func (u *userRepository) GetAllUsers(ctx context.Context) ([]*ent.User, error) {
db, err := database.SetupDatabase(ctx)
if err != nil {
return nil, err
}
defer db.Close()
users, err := db.User.Query().All(ctx)
if err != nil {
return nil, err
}
return users, nil
}
func (u *userRepository) GetUserByUID(ctx context.Context, uid string) (*ent.User, error) {
db, err := database.SetupDatabase(ctx)
if err != nil {
return nil, err
}
defer db.Close()
user, err := db.User.Query().
Where(entUser.UIDEQ(uid)).
Where(entUser.DeletedAtIsNil()).
Only(ctx)
if err != nil {
return nil, err
}
return user, nil
}
func (u *userRepository) GetUserByEmail(ctx context.Context, email string) (*ent.User, error) {
db, err := database.SetupDatabase(ctx)
if err != nil {
return nil, err
}
defer db.Close()
user, err := db.User.Query().
Where(entUser.EmailEQ(email)).
Where(entUser.DeletedAtIsNil()).
Only(ctx)
if err != nil {
return nil, err
}
return user, nil
}
func (u *userRepository) UpdateUserByUID(
ctx context.Context,
uid string,
lastName string,
firstName string,
email string,
) (*ent.User, error) {
db, err := database.SetupDatabase(ctx)
if err != nil {
return nil, err
}
defer db.Close()
user, err := db.User.Query().
Where(entUser.UIDEQ(uid)).
Where(entUser.DeletedAtIsNil()).
Only(ctx)
if err != nil {
return nil, err
}
updateUser := user.Update()
if lastName != "" {
updateUser = updateUser.SetLastName(lastName)
}
if firstName != "" {
updateUser = updateUser.SetFirstName(firstName)
}
if email != "" {
updateUser = updateUser.SetEmail(email)
}
user, err = updateUser.Save(ctx)
if err != nil {
return nil, err
}
return user, nil
}
func (u *userRepository) DeleteUserByUID(ctx context.Context, uid string) error {
db, err := database.SetupDatabase(ctx)
if err != nil {
return err
}
defer db.Close()
user, err := db.User.Query().
Where(entUser.UIDEQ(uid)).
Where(entUser.DeletedAtIsNil()).
Only(ctx)
if err != nil {
return err
}
// 現在の日時を文字列で取得
date := time.Now()
dateString := date.Format("2006-01-02 15:04:05")
// 更新用のemailの値を設定
updateEmail := user.Email + dateString
_, err = user.Update().
SetEmail(updateEmail).
SetDeletedAt(date).
Save(ctx)
if err != nil {
return err
}
return nil
}
※対象データの削除処理については論理削除するのが基本です。
・「src/internal/services/user/user.go」
package user
import (
"fmt"
"net/http"
"go-echo-v2/internal/repositories/user"
"go-echo-v2/util/logger"
utilContext "go-echo-v2/util/context"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
// 型定義
type CreateUserRequestBody struct {
LastName string `json:"last_name" validate:"required"`
FirstName string `json:"first_name" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
type CreateUserResponse struct {
UID string `json:"uid"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
Email string `json:"email"`
}
type UserResponse struct {
ID int64 `json:"id"`
UID string `json:"uid"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
Email string `json:"email"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
DeletedAt string `json:"deleted_at"`
}
type UpdateUserRequestBody struct {
LastName string `json:"last_name" validate:"omitempty"`
FirstName string `json:"first_name" validate:"omitempty"`
Email string `json:"email" validate:"omitempty,email"`
}
// インターフェースの定義
type UserService interface {
CreateUser(c echo.Context) error
GetAllUsers(c echo.Context) error
GetUserByUID(c echo.Context) error
UpdateUserByUID(c echo.Context) error
DeleteUserByUID(c echo.Context) error
}
// 構造体の定義
type userService struct {
userRepository user.UserRepository
}
// インスタンス生成用関数の定義
func NewUserService(
userRepository user.UserRepository,
) UserService {
return &userService{
userRepository: userRepository,
}
}
// ユーザー作成
func (s *userService) CreateUser(c echo.Context) error {
ctx := utilContext.CreateContext(c)
var r CreateUserRequestBody
if err := c.Bind(&r); err != nil {
msg := fmt.Sprintf("リクエストボディが不正です。: %v", err)
logger.Warn(ctx, msg)
return echo.NewHTTPError(http.StatusBadRequest, msg)
}
// バリデーションチェック
if err := c.Validate(&r); err != nil {
msg := fmt.Sprintf("バリデーションエラー: %v", err)
logger.Warn(ctx, msg)
return echo.NewHTTPError(http.StatusUnprocessableEntity, msg)
}
// UIDの設定
uid := uuid.New().String()
// ユーザー作成
user, err := s.userRepository.CreateUser(ctx, uid, r.LastName, r.FirstName, r.Email)
if err != nil {
msg := fmt.Sprintf("ユーザーを作成できませんでした。: %v", err)
logger.Error(ctx, msg)
return echo.NewHTTPError(http.StatusInternalServerError, msg)
}
res := CreateUserResponse{
UID: user.UID,
LastName: user.LastName,
FirstName: user.FirstName,
Email: user.Email,
}
return c.JSON(http.StatusCreated, res)
}
// 有効な全てのユーザーを取得
func (s *userService) GetAllUsers(c echo.Context) error {
ctx := utilContext.CreateContext(c)
users, err := s.userRepository.GetAllUsers(ctx)
if len(users) == 0 || err != nil {
// データが0件またはエラーの場合は空の配列を返す
res := []map[string]interface{}{}
return c.JSON(http.StatusOK, res)
}
// レスポンス結果の整形
var res []UserResponse
for _, user := range users {
// 日付項目のフォーマット変換
createdAt := user.CreatedAt.Format("2006-01-02 15:04:05")
updatedAt := user.UpdatedAt.Format("2006-01-02 15:04:05")
deletedAt := ""
if user.DeletedAt != nil {
deletedAt = user.DeletedAt.Format("2006-01-02 15:04:05")
}
res = append(res, UserResponse{
ID: user.ID,
UID: user.UID,
LastName: user.LastName,
FirstName: user.FirstName,
Email: user.Email,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
})
}
return c.JSON(http.StatusOK, res)
}
// uidから対象のユーザーを取得
func (s *userService) GetUserByUID(c echo.Context) error {
ctx := utilContext.CreateContext(c)
// パスパラメータからuidを取得
uid := c.Param("uid")
// TODO: 認可チェックを入れる
user, err := s.userRepository.GetUserByUID(ctx, uid)
if user == nil || err != nil {
// データが存在しない、またはエラーの場合は空のオブジェクトを返す
res := map[string]interface{}{}
return c.JSON(http.StatusOK, res)
}
// レスポンス結果の整形
createdAt := user.CreatedAt.Format("2006-01-02 15:04:05")
updatedAt := user.UpdatedAt.Format("2006-01-02 15:04:05")
deletedAt := ""
if user.DeletedAt != nil {
deletedAt = user.DeletedAt.Format("2006-01-02 15:04:05")
}
res := UserResponse{
ID: user.ID,
UID: user.UID,
LastName: user.LastName,
FirstName: user.FirstName,
Email: user.Email,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
}
return c.JSON(http.StatusOK, res)
}
// uidから対象のユーザーを更新
func (s *userService) UpdateUserByUID(c echo.Context) error {
ctx := utilContext.CreateContext(c)
// パスパラメータからuidを取得
uid := c.Param("uid")
// TODO: 認可チェックを入れる
var r UpdateUserRequestBody
if err := c.Bind(&r); err != nil {
msg := fmt.Sprintf("リクエストボディが不正です。: %v", err)
logger.Warn(ctx, msg)
return echo.NewHTTPError(http.StatusBadRequest, msg)
}
// バリデーションチェック
if r.LastName == "" && r.FirstName == "" && r.Email == "" {
msg := "リクエスボディが空です。"
logger.Warn(ctx, msg)
return echo.NewHTTPError(http.StatusUnprocessableEntity, msg)
}
if err := c.Validate(&r); err != nil {
msg := fmt.Sprintf("バリデーションエラー: %v", err)
logger.Warn(ctx, msg)
return echo.NewHTTPError(http.StatusUnprocessableEntity, msg)
}
user, err := s.userRepository.UpdateUserByUID(
ctx,
uid,
r.LastName,
r.FirstName,
r.Email,
)
if err != nil {
msg := fmt.Sprintf("ユーザーを更新できませんでした。: %v", err)
logger.Error(ctx, msg)
return echo.NewHTTPError(http.StatusInternalServerError, msg)
}
// レスポンス結果の整形
createdAt := user.CreatedAt.Format("2006-01-02 15:04:05")
updatedAt := user.UpdatedAt.Format("2006-01-02 15:04:05")
deletedAt := ""
if user.DeletedAt != nil {
deletedAt = user.DeletedAt.Format("2006-01-02 15:04:05")
}
res := UserResponse{
ID: user.ID,
UID: user.UID,
LastName: user.LastName,
FirstName: user.FirstName,
Email: user.Email,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
}
return c.JSON(http.StatusOK, res)
}
// uidから対象ユーザーを論理削除
func (s *userService) DeleteUserByUID(c echo.Context) error {
ctx := utilContext.CreateContext(c)
// パスパラメータからuidを取得
uid := c.Param("uid")
// TODO: 認可チェックを入れる
err := s.userRepository.DeleteUserByUID(ctx, uid)
if err != nil {
msg := fmt.Sprintf("ユーザーを削除できませんでした。: %v", err)
logger.Error(ctx, msg)
return echo.NewHTTPError(http.StatusInternalServerError, msg)
}
res := map[string]interface{}{
"message": "OK",
}
return c.JSON(http.StatusOK, res)
}
※ent.でDBからデータ取得した場合、上記のDeletedAtのようなnilになる項目が取得できないため、nilでもレスポンス結果に項目を出力させたい場合は、レスポンス結果の整形処理が必要になります。
・「src/internal/handlers/user/user.go」
package user
import (
repoUser "go-echo-v2/internal/repositories/user"
"go-echo-v2/internal/services/user"
"github.com/labstack/echo/v4"
)
// OpenAPI仕様書用の型定義
type CreateUserRequestBody struct {
LastName string `json:"last_name" validate:"required" example:"山田"`
FirstName string `json:"first_name" validate:"required" example:"太郎"`
Email string `json:"email" validate:"required,email" example:"t.yamada@example.com"`
}
type CreateUserResponse struct {
UID string `json:"uid" example:"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`
LastName string `json:"last_name" example:"山田"`
FirstName string `json:"first_name" example:"太郎"`
Email string `json:"email" example:"t.yamada@example.com"`
}
type UserResponse struct {
ID int64 `json:"id" example:"1"`
UID string `json:"uid" example:"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
LastName string `json:"last_name" example:"山田"`
FirstName string `json:"first_name" example:"太郎"`
Email string `json:"email" example:"t.yamada@example.com"`
CreatedAt string `json:"created_at" example:"2025-03-15 18:08:00"`
UpdatedAt string `json:"updated_at" example:"2025-03-15 18:08:00"`
DeletedAt string `json:"deleted_at" example:""`
}
type UpdateUserRequestBody struct {
LastName string `json:"last_name" example:"佐藤"`
FirstName string `json:"first_name" example:"太郎"`
Email string `json:"email" validate:"email" example:"t.sato@example.com"`
}
type OKResponse struct {
Message string `json:"message" example:"OK"`
}
type BadRequestResponse struct {
Message string `json:"message" example:"リクエストボディが不正です。: error message"`
}
type UnauthorizedResponse struct {
Message string `json:"message" example:"Unauthorized"`
}
type UnprocessableEntityResponse struct {
Message string `json:"message" example:"バリデーションエラー: error message"`
}
type InternalServerErrorResponse struct {
Message string `json:"message" example:"Internal Server Error: error message"`
}
// @Description ユーザー作成API
// @Tags user
// @Param CreateUserRequestBody body CreateUserRequestBody true "作成するユーザー情報"
// @Success 201 {object} CreateUserResponse
// @Failure 400 {object} BadRequestResponse
// @Failure 422 {object} UnprocessableEntityResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/user [post]
func CreateUser(c echo.Context) error {
// インスタンス生成
userRepository := repoUser.NewUserRepository()
userService := user.NewUserService(userRepository)
// サービス実行
return userService.CreateUser(c)
}
// @Description 全てのユーザー取得API <br/> ※削除済みユーザー含む
// @Tags user
// @Security Bearer
// @Success 200 {object} []UserResponse "対象データが存在しない場合は空の配列「[]」を返す。"
// @Failure 401 {object} UnauthorizedResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/users [get]
func GetAllUsers(c echo.Context) error {
// インスタンス生成
userRepository := repoUser.NewUserRepository()
userService := user.NewUserService(userRepository)
// サービス実行
return userService.GetAllUsers(c)
}
// @Description 有効な対象ユーザー取得API
// @Tags user
// @Security Bearer
// @Param uid path string true "uid"
// @Success 200 {object} UserResponse "対象データが存在しない場合は空のオブジェクト「{}」を返す。"
// @Failure 401 {object} UnauthorizedResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/user/:uid [get]
func GetUserByUID(c echo.Context) error {
// インスタンス生成
userRepository := repoUser.NewUserRepository()
userService := user.NewUserService(userRepository)
// サービス実行
return userService.GetUserByUID(c)
}
// @Description 対象ユーザー更新API
// @Tags user
// @Security Bearer
// @Param uid path string true "uid"
// @Param UpdateUserRequestBody body UpdateUserRequestBody true "更新するユーザー情報"
// @Success 200 {object} UserResponse
// @Failure 400 {object} BadRequestResponse
// @Failure 401 {object} UnauthorizedResponse
// @Failure 422 {object} UnprocessableEntityResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/user/:uid [put]
func UpdateUserByUID(c echo.Context) error {
// インスタンス生成
userRepository := repoUser.NewUserRepository()
userService := user.NewUserService(userRepository)
// サービス実行
return userService.UpdateUserByUID(c)
}
// @Description 対象ユーザー削除API
// @Tags user
// @Security Bearer
// @Param uid path string true "uid"
// @Success 200 {object} OKResponse
// @Failure 401 {object} UnauthorizedResponse
// @Failure 500 {object} InternalServerErrorResponse
// @Router /api/v1/user/:uid [delete]
func DeleteUserByUID(c echo.Context) error {
// インスタンス生成
userRepository := repoUser.NewUserRepository()
userService := user.NewUserService(userRepository)
// サービス実行
return userService.DeleteUserByUID(c)
}
・「src/internal/handlers/user/user_1_common_test.go」
package user
import (
"context"
"testing"
"go-echo-v2/database"
"go-echo-v2/ent"
entUser "go-echo-v2/ent/user"
"go-echo-v2/middleware"
"go-echo-v2/util/validator"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
)
/*
* ユーザーAPIのテスト用共通処理
*/
// テスト用echoの初期化処理
func initTestEcho() *echo.Echo {
e := echo.New()
// ミドルウェアの設定
e.Use(middleware.RequestMiddleware)
e.Use(middleware.LoggerMiddleware())
e.Use(middleware.CorsMiddleware())
e.Use(echoMiddleware.Recover())
// バリデーター設定
e.Validator = validator.NewCustomValidator()
return e
}
// テストデータ削除処理
func clearTestDB(t *testing.T) {
ctx := context.Background()
db, err := database.SetupDatabase(ctx)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// ユーザーデータ削除
_, err = db.User.
Delete().
Exec(ctx)
if err != nil {
t.Fatal(err)
}
}
// テスト用ユーザーデータ登録Seeder
func userSeeder(t *testing.T) {
ctx := context.Background()
db, err := database.SetupDatabase(ctx)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// テスト用ユーザーデータ作成
_, err = db.User.Create().
SetUID("test-1").
SetLastName("姓1").
SetFirstName("名1").
SetEmail("test-user1@test.com").
Save(ctx)
if err != nil {
t.Fatal(err)
}
_, err = db.User.Create().
SetUID("test-2").
SetLastName("姓2").
SetFirstName("名2").
SetEmail("test-user2@test.com").
Save(ctx)
if err != nil {
t.Fatal(err)
}
}
// ユーザー取得
func getUserByUID(t *testing.T, uid string) *ent.User {
ctx := context.Background()
db, err := database.SetupDatabase(ctx)
if err != nil {
t.Fatal(err)
}
defer db.Close()
user, err := db.User.Query().
Where(entUser.UIDEQ(uid)).
Only(ctx)
if err != nil {
t.Fatal(err)
}
return user
}
※APIの種類が多い場合はテストコードを分割した方がいいです。
・「src/internal/handlers/user/user_2_test.go」
package user
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
)
/*
* ユーザー作成APIのテスト
*/
// 正常系
func TestCreateUserOK(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ルーティング設定
e := initTestEcho()
v1 := e.Group("/api/v1")
v1.POST("/user", CreateUser)
// テスト用リクエストの作成
reqBody := map[string]interface{}{
"last_name": "姓",
"first_name": "名",
"email": "mei.sei@test.com",
}
jsonReqBody, err := json.Marshal(reqBody)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/user", bytes.NewBuffer(jsonReqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果をJSON形式で取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
// 検証
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, "姓", resbody["last_name"])
assert.Equal(t, "名", resbody["first_name"])
assert.Equal(t, "mei.sei@test.com", resbody["email"])
// テストデータ削除処理
clearTestDB(t)
}
・「src/internal/handlers/user/user_3_test.go」
package user
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"go-echo-v2/middleware"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
)
/*
* 全てのユーザー取得APIのテスト
*/
// 正常系
func TestGetAllUsersOK(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ルーティング設定
e := initTestEcho()
v1 := e.Group("/api/v1")
v1.GET("/users", GetAllUsers, middleware.AuthMiddleware)
// テスト用データ登録
userSeeder(t)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
bearerToken := "Bearer " + token
req.Header.Set("Authorization", bearerToken)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果をJSON形式で取得
var resbody []map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
// 検証
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, 2, len(resbody))
assert.Equal(t, "test-1", resbody[0]["uid"])
assert.Equal(t, "姓1", resbody[0]["last_name"])
assert.Equal(t, "名1", resbody[0]["first_name"])
assert.Equal(t, "test-user1@test.com", resbody[0]["email"])
assert.NotEmpty(t, resbody[0]["created_at"])
assert.NotEmpty(t, resbody[0]["updated_at"])
assert.Empty(t, resbody[0]["deleted_at"])
assert.Equal(t, "test-2", resbody[1]["uid"])
assert.Equal(t, "姓2", resbody[1]["last_name"])
assert.Equal(t, "名2", resbody[1]["first_name"])
assert.Equal(t, "test-user2@test.com", resbody[1]["email"])
assert.NotEmpty(t, resbody[1]["created_at"])
assert.NotEmpty(t, resbody[1]["updated_at"])
assert.Empty(t, resbody[1]["deleted_at"])
// テストデータ削除処理
clearTestDB(t)
}
・「src/internal/handlers/user/user_4_test.go」
package user
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"go-echo-v2/middleware"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
)
/*
* 有効な対象ユーザー取得APIのテスト
*/
// 正常系
func TestGetUserByUIDOK(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ルーティング設定
e := initTestEcho()
v1 := e.Group("/api/v1")
v1.GET("/user/:uid", GetUserByUID, middleware.AuthMiddleware)
// テスト用データ登録
userSeeder(t)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/test-1", nil)
token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
bearerToken := "Bearer " + token
req.Header.Set("Authorization", bearerToken)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果をJSON形式で取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
// 検証
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "test-1", resbody["uid"])
assert.Equal(t, "姓1", resbody["last_name"])
assert.Equal(t, "名1", resbody["first_name"])
assert.Equal(t, "test-user1@test.com", resbody["email"])
assert.NotEmpty(t, resbody["created_at"])
assert.NotEmpty(t, resbody["updated_at"])
assert.Empty(t, resbody["deleted_at"])
// テストデータ削除処理
clearTestDB(t)
}
・「src/internal/handlers/user/user_5_test.go」
package user
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"go-echo-v2/middleware"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
)
/*
* 対象ユーザー更新APIのテスト
*/
// 正常系
func TestUpdateUserByUID(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ルーティング設定
e := initTestEcho()
v1 := e.Group("/api/v1")
v1.PUT("/user/:uid", UpdateUserByUID, middleware.AuthMiddleware)
// テスト用データ登録
userSeeder(t)
// テスト用リクエストの作成
reqBody := map[string]interface{}{
"last_name": "更新姓",
"first_name": "更新名",
"email": "update-user@test.com",
}
jsonReqBody, err := json.Marshal(reqBody)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/test-1", bytes.NewBuffer(jsonReqBody))
req.Header.Set("Content-Type", "application/json")
token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
bearerToken := "Bearer " + token
req.Header.Set("Authorization", bearerToken)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果をJSON形式で取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
// 検証
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "test-1", resbody["uid"])
assert.Equal(t, "更新姓", resbody["last_name"])
assert.Equal(t, "更新名", resbody["first_name"])
assert.Equal(t, "update-user@test.com", resbody["email"])
assert.NotEmpty(t, resbody["created_at"])
assert.NotEmpty(t, resbody["updated_at"])
assert.Empty(t, resbody["deleted_at"])
// テストデータ削除処理
clearTestDB(t)
}
・「src/internal/handlers/user/user_6_test.go」
package user
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"go-echo-v2/middleware"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
)
/*
* 対象ユーザー削除APIのテスト
*/
// 正常系
func TestDeleteUserByUID(t *testing.T) {
// .env ファイルの読み込み
if err := godotenv.Load("../../../.env"); err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
// テスト用のENV設定
env := os.Getenv("ENV")
os.Setenv("ENV", "testing")
defer os.Setenv("ENV", env)
// ルーティング設定
e := initTestEcho()
v1 := e.Group("/api/v1")
v1.DELETE("/user/:uid", DeleteUserByUID, middleware.AuthMiddleware)
// テスト用データ登録
userSeeder(t)
// テスト用リクエストの作成
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/test-1", nil)
token := "zMdtq_glzI7oqq8yXjMgEOW6XfrSUMFGqw"
bearerToken := "Bearer " + token
req.Header.Set("Authorization", bearerToken)
rec := httptest.NewRecorder()
// テスト実行
e.ServeHTTP(rec, req)
// レスポンス結果をJSON形式で取得
var resbody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resbody); err != nil {
t.Fatal(err)
}
// DBから対象ユーザーを取得
user := getUserByUID(t, "test-1")
// 検証
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "OK", resbody["message"])
assert.NotEmpty(t, user.DeletedAt)
// テストデータ削除処理
clearTestDB(t)
}
次に「src/router/router.go」と「src/main.go」を以下のように修正します。
・「src/router/router.go」
package router
import (
"go-echo-v2/internal/handlers/healthcheck"
"go-echo-v2/internal/handlers/index"
"go-echo-v2/internal/handlers/user"
"go-echo-v2/middleware"
"github.com/labstack/echo/v4"
)
func SetupRouter(e *echo.Echo) {
e.GET("/", index.Index)
v1 := e.Group("/api/v1")
v1.GET("/", index.Index, middleware.AuthMiddleware)
v1.GET("/healthcheck", healthcheck.Healthcheck, middleware.ApiKeyAuthMiddleware)
v1.POST("/user", user.CreateUser)
v1.GET("/users", user.GetAllUsers, middleware.AuthMiddleware)
v1.GET("/user/:uid", user.GetUserByUID, middleware.AuthMiddleware)
v1.PUT("/user/:uid", user.UpdateUserByUID, middleware.AuthMiddleware)
v1.DELETE("/user/:uid", user.DeleteUserByUID, middleware.AuthMiddleware)
}
・「src/main.go」
package main
import (
"fmt"
"log/slog"
"os"
_ "go-echo-v2/docs"
"go-echo-v2/middleware"
"go-echo-v2/router"
"go-echo-v2/util/validator"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
echoSwagger "github.com/swaggo/echo-swagger"
)
// @title go-echo-v2 API
// @version 1.0
// @description Go言語(Golang)のフレームワーク「Echo」によるAPI開発サンプルのバージョン2
// @host localhost:8080
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and token.
func main() {
// .env ファイルの読み込み
err := godotenv.Load()
if err != nil {
slog.Error(".envファイルの読み込みに失敗しました。")
}
e := echo.New()
// ミドルウェアの設定
e.Use(middleware.RequestMiddleware)
e.Use(middleware.LoggerMiddleware())
e.Use(middleware.CorsMiddleware())
e.Use(echoMiddleware.Recover()) // panic発生時にサーバー停止を防ぐ
// バリデーション設定
e.Validator = validator.NewCustomValidator()
// ルーティング設定
router.SetupRouter(e)
// API仕様書の設定
env := os.Getenv("ENV")
if env != "production" {
e.GET("/swagger/*", echoSwagger.WrapHandler)
}
// ポート番号の設定
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
startPort := fmt.Sprintf(":%s", port)
// サーバー起動
e.Logger.Fatal(e.Start(startPort))
}
次に以下のコマンドも実行し、各種ファイルを整えておきます。
$ docker compose exec api go mod tidy
$ docker compose exec api mockgen -source=./internal/repositories/user/user.go -destination=./internal/repositories/user/mock_user/mock_user.go
$ docker compose exec api go fmt ./...
$ docker compose exec api staticcheck ./...
$ docker compose exec api swag i
次に作成したユーザーAPIをPostmanを使って試してみます。
まずはユーザー作成API「http://localhost:8080/api/v1/user」をPOSTメソッドで実行し、下図のようにステータスコード201で正常終了すればOKです。
次に全てのユーザー取得API「http://localhost:8080/api/v1/users」をGETメソッドで実行し、下図のように作成したユーザーが取得できればOKです。
※Bearerトークンは適当な値を設定して下さい。
次に対象ユーザー取得API「http://localhost:8080/api/v1/user/[対象ユーザーのuid]」をGETメソッドで実行し、下図のように対象ユーザーが取得できればOKです。
※Bearerトークンは適当な値を設定して下さい。
次に対象ユーザー取得API「http://localhost:8080/api/v1/user/[対象ユーザーのuid]」をPUTメソッドで実行し、下図のように対象ユーザーが更新できればOKです。
※Bearerトークンは適当な値を設定して下さい。
次に対象ユーザー取得API「http://localhost:8080/api/v1/user/[対象ユーザーのuid]」をDELETEメソッドで実行し、下図のように正常終了すればOKです。
※Bearerトークンは適当な値を設定して下さい。
次にもう一度対象ユーザー取得APIを実行し、下図のように削除済みユーザーが取得できずに正常終了すればOKです。
次にもう一度全てのユーザー取得APIを実行し、論理削除済みユーザーが取得できればOKです。
次に以下のコマンドを実行し、テストコードも試してみます。
※事前にテストDBにもマイグレーションが必要です。
$ docker compose exec api go test -v ./internal/handlers/...
テスト実行後、以下のように全てのテストがパスすればOKです。
次にブラウザで「http://localhost:8080/swagger/index.html」を開き、OpenAPIの仕様書も以下のように反映されていればOKです。
カスタムバリデーションを作りたい場合
リクエストパラメータのバリデーションチェックなどでカスタムバリデーションを作りたい場合、ファイル「src/util/validator/validator.go」を以下のように記述すると追加できます。
※既存のバリデーションチェックのエラーメッセージをカスタマイズすることも可能です
・「src/util/validator/validator.go」
package validator
import (
"strconv"
"fmt"
"github.com/go-playground/validator/v10"
)
type customValidator struct {
validator *validator.Validate
}
// カラムのバイト数チェック
func validateColumnByteSize(fl validator.FieldLevel) bool {
value := fl.Field().String()
param := fl.Param()
checkByte, err := strconv.Atoi(param)
if err != nil {
return false
}
return len(value) <= checkByte
}
func (cv *customValidator) Validate(i interface{}) error {
err := cv.validator.Struct(i)
if err != nil {
// カスタムエラーメッセージの設定
var errMsg string
for _, err := range err.(validator.ValidationErrors) {
switch err.ActualTag() {
case "byte-size":
errMsg += fmt.Sprintf("%sは%sバイト以下で入力して下さい。", err.Field(), err.Param())
default:
errMsg += err.Error()
}
}
return fmt.Errorf(errMsg)
}
return nil
}
func NewCustomValidator() *customValidator {
v := validator.New()
// カスタムバリデーション設定
v.RegisterValidation("byte-size", validateColumnByteSize)
return &customValidator{
validator: v,
}
}
あとはリクエストボディの型定義などで使って下さい。
type CreateUserRequestBody struct {
LastName string `json:"last_name" validate:"required,byte-size=255"`
FirstName string `json:"first_name" validate:"required,byte-size=255"`
Email string `json:"email" validate:"required,email,byte-size=255"`
}
本番環境用のDockerコンテナを作る
ローカル開発環境ではdocker-composeを使って各種コンテナを立てて開発しましたが、API用のコンテナを本番環境にデプロイしたい場合は、API用のコンテナを単品でビルドする必要があります。
これについてもローカル環境でビルドして試しておかないと、いざ本番環境へデプロイした際に上手くいかないということがあるので注意しましょう。
では以下のコマンドを実行し、各種ファイルを作成します。
$ cd src && touch .env.production && cd ..
$ mkdir -p docker/prod && cd docker/prod && touch Dockerfile && cd ../..
次に作成したファイルをそれぞれ以下のように記述します。
・「src/.env.production」
ENV=local
PORT=8080
POSTGRES_HOST=host.docker.internal
POSTGRES_PORT=5432
POSTGRES_DB=pg-db
POSTGRES_USER=pg-user
POSTGRES_PASSWORD=pg-password
GO_ECHO_V2_API_KEY=208c190373d51328cfda7b27993925bcc4c5edd0b50593f0a23cb730493f4711
※本番環境用の機密情報を含まない環境変数の設定用として「.env.production」を使いますが、実際の本番環境における機密情報を含む環境変数についてはインフラの方のサービスなどで設定するようにして下さい。(DB接続情報やAPIキーはローカルで確認するために今回は付けています。)また、ENVの値は本来「production」にする必要がありますが、今回利用したPostgreSQLのDB接続情報のオプションで「sslmode=disable」を付けないとhttp接続ができないため、ここでは「local」にしてローカル環境用の接続設定を使うようにしています。
####################
# ビルドステージ
####################
FROM golang:1.24-alpine3.21 AS builder
WORKDIR /go/src
COPY ./src .
# 依存関係をインストール
RUN go install
# ビルド
RUN go build -o main .
####################
# 実行ステージ
####################
FROM alpine:3.21 AS runner
WORKDIR /go/src
# コンテナ用ユーザー作成
RUN addgroup --system --gid 1001 appuser && \
adduser --system --uid 1001 appuser
# ビルドステージで作成したバイナリをコピー
COPY --from=builder --chown=appuser:appuser ./go/src/main .
COPY --from=builder --chown=appuser:appuser ./go/src/.env.production ./.env
# ポートを設定
EXPOSE 8080
# コンテナ起動ユーザー設定
USER appuser
# APIサーバー起動コマンド
CMD ["./main"]
$ docker build --no-cache -f ./docker/prod/Dockerfile -t go-echo-v2-api:latest .
$ docker run -d -p 80:8080 go-echo-v2-api:latest

次に全てのユーザー取得API「http://localhost/api/v1/users」を実行し、上記でDBに登録したユーザーが取得できればOKです。
最後に
今回はGoのEchoでシンプルかつ実務的にAPIを開発する方法(バージョン2)について解説しました。
私がこれまで磨いてきたものをこの記事に残しておいたので、これからGolangのAPI開発を試したい方はぜひ参考にしてみて下さい!
また、前回の記事も一部参考になる部分があると思うので、そちらも合わせて参考にしていただけるといいと思います。
コメント