API の堅牢性は、境界で不正入力をどれだけ殺せるかでほぼ決まります。"abc" を ID として受けて DB に投げる、必須項目が空のまま登録する、IsAdmin: true を勝手に渡される——これらは「あとでチェックする」のでは遅く、型に変換できた時点で正しい状態を作るのが本番の作法です。
この記事は、Go Echo 本番運用ガイドの入力処理・エラー設計編です。Echo v5 の バインディング → バリデーション → エラー整形 を、「外部入力は信用しない」というセキュリティ原則に沿って実装します。
この記事のルール:API は Echo 公式ドキュメント(v5・2026年6月時点) に基づきます。v5 はバインダの署名(
Bind(c, target))が反転し、ジェネリックな型安全抽出(PathParam[T])が入り、HTTPError.Messageがstring化、エラーハンドラ署名がfunc(c *echo.Context, err error)に反転するなど、v4 から差分が大きい領域です。本番投入前に必ず公式で最新を確認してください。
1. c.Bind:4つのソースを構造体タグで束ねる
c.Bind(&dto) は、1回の呼び出しで**パスパラメータ・クエリ・リクエストボディ(JSON/XML/フォーム)**を、構造体タグに従って流し込みます。
type SearchRequest struct {
Keyword string `query:"q"`
Tags []string `query:"tag"` // 繰り返し ?tag=go&tag=web → ["go","web"]
Page int `query:"page"`
Category string `param:"category"` // パスパラメータ /search/:category
}
func search(c *echo.Context) error {
var req SearchRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
}
// req は型変換済み(page は int、tags は []string)
return c.JSON(http.StatusOK, req)
}
| タグ | ソース |
|---|---|
json | リクエストボディ(JSON) |
xml | リクエストボディ(XML) |
form | フォームデータ |
query | クエリパラメータ |
param | パスパラメータ |
header | ヘッダ(c.Bind の対象外。後述) |
重要な落とし穴:ヘッダは
c.Bindでは束ねられません。Authorizationや独自ヘッダを構造体に取り込みたいときは、echo.BindHeaders(c, &dto)を明示的に呼びます。「Bind したのにヘッダが空」はこれが原因の筆頭です。
1.1 単一ソースだけをバインドする
「ボディだけ」「クエリだけ」を厳密にバインドしたいときは、ソース別の関数を使います。意図しないソースからの流入を防げます。
echo.BindBody(c, &payload) // ボディのみ
echo.BindQueryParams(c, &payload) // クエリのみ
echo.BindPathValues(c, &payload) // パスパラメータのみ
echo.BindHeaders(c, &payload) // ヘッダのみ
2. mass assignment を防ぐ:DTO をビジネス構造体と分ける
ここがセキュリティの肝です。DB エンティティやドメインモデルに直接 Bind してはいけません。
// ❌ 危険:DB エンティティに直接 Bind
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"` // 攻撃者が {"is_admin": true} を送れば昇格できる
}
func badCreate(c *echo.Context) error {
var u User
_ = c.Bind(&u) // IsAdmin まで外部から上書きされる(mass assignment 脆弱性)
// ...
}
// ✅ 安全:受け口専用の DTO を定義し、許可したフィールドだけを受ける
type CreateUserDTO struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=12"`
// IsAdmin は DTO に存在しない=外部から絶対にセットできない
}
func (h *UserHandler) Create(c *echo.Context) error {
var dto CreateUserDTO
if err := c.Bind(&dto); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if err := c.Validate(&dto); err != nil {
return err
}
// DTO → ドメインへ「明示的に」詰め替える。権限は常にサーバーが決める
user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email, dto.Password)
if err != nil {
return err
}
return c.JSON(http.StatusCreated, toUserResponse(user)) // 出力も専用 DTO
}
公式も「ビジネス構造体に直接バインドせず、専用 DTO を使って明示的にマッピングし、意図しないフィールド露出を防げ」と明記しています。入力 DTO と出力 DTO を分け、Password のような機微フィールドを出力に絶対載せないのも同じ原則です。
3. ジェネリック型安全バインダ:単一の値を型付きで取る
ID やページ番号のような単一の値は、構造体を切るまでもなく型安全に取りたいものです。v5 はジェネリクスを使った型安全抽出を提供します。変換できなければエラーになるので、"abc" を int64 ID として受ける事故を構造的に防げます。
// パスパラメータ /users/:id を int64 として取得
id, err := echo.PathParam[int64](c, "id")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "id must be an integer")
}
// クエリ ?active=true を bool として取得
active, err := echo.QueryParam[bool](c, "active")
3.1 Fluent バインダ:複数の値をまとめて変換しエラーを集約
複数のクエリ/パラメータを一括で型変換し、エラーをまとめて拾うときは Fluent(流れるような)バインダが便利です。
var opts struct {
IDs []int64
Active bool
Limit int64
}
err := echo.QueryParamsBinder(c).
Int64s("id", &opts.IDs). // ?id=1&id=2 → []int64{1,2}
Bool("active", &opts.Active).
Int64("limit", &opts.Limit).
BindError() // 最初のエラー(または集約)を返す
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid query parameters")
}
PathValuesBinder(c) / FormFieldBinder(c) も同系統です。各メソッドには Int64(任意)/MustInt64(必須・無ければエラー)/Int64s(複数)といった派生があり、必須・任意・複数値を型で表現できます。
4. バリデーション:go-playground/validator を被せる
Echo はバリデーションライブラリを同梱しません。代わりに echo.Validator インターフェースという差し込み口を用意しています。デファクトの github.com/go-playground/validator/v10 を被せるのが定番です。
import "github.com/go-playground/validator/v10"
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i any) error {
if err := cv.validator.Struct(i); err != nil {
return err // ここでは生エラーを返し、整形は集中エラーハンドラに任せる
}
return nil
}
func main() {
e := echo.New()
e.Validator = &CustomValidator{validator: validator.New()}
// ...
}
validate:"..." タグでルールを宣言します。よく使うものを挙げます。
| タグ | 意味 |
|---|---|
required | 必須(ゼロ値を拒否) |
email | メール形式 |
min=2,max=50 | 文字列長/数値範囲 |
gte=0,lte=100 | 以上/以下 |
oneof=draft published | 列挙のいずれか |
uuid4 | UUID v4 形式 |
dive | スライス/マップの各要素に適用 |
4.1 バリデーションエラーを「フィールド単位の 422」に整形する
{"message":"Key: 'CreateUserDTO.Email' Error:Field validation..."} という生のエラーをそのまま返すのは、API として不親切で情報も漏れます。本番ではどのフィールドがなぜ落ちたかを構造化して 422 Unprocessable Entity で返します。
type FieldError struct {
Field string `json:"field"`
Rule string `json:"rule"`
Message string `json:"message"`
}
// validator のエラーをフィールド単位に変換する
func toFieldErrors(err error) []FieldError {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
return nil
}
out := make([]FieldError, 0, len(ve))
for _, fe := range ve {
out = append(out, FieldError{
Field: fe.Field(),
Rule: fe.Tag(),
Message: humanize(fe), // "email は正しい形式で入力してください" 等
})
}
return out
}
この変換は、次章の集中エラーハンドラで一度だけ行います。各ハンドラに整形ロジックを散らさないのが DRY の要点です。
5. エラーハンドリング:握りつぶさず return し、一箇所で整形する
Echo のエラー処理は中央集権です。ハンドラ/ミドルウェアは error を return するだけで、変換は1つの HTTPErrorHandler が担います。
// ステータス付きエラー(v5 はメッセージが string・非可変長)
return echo.NewHTTPError(http.StatusNotFound, "user not found")
// メッセージ省略時はステータステキスト
return echo.NewHTTPError(http.StatusBadGateway)
// 定義済みセンチネルエラー
return echo.ErrUnauthorized // 401
return echo.ErrForbidden // 403
5.1 本番向け集中エラーハンドラ
要件は4つ。(1) バリデーションエラーは 422 にフィールド単位で整形、(2) *echo.HTTPError はそのステータス、(3) それ以外(想定外)は 500 で詳細を隠す、(4) 5xx は相関 ID 付きでログへ。v5 の署名は func(c *echo.Context, err error)(v4 から引数順が反転)です。
type ErrorResponse struct {
Error string `json:"error"`
Fields []FieldError `json:"fields,omitempty"`
}
func customHTTPErrorHandler(c *echo.Context, err error) {
// すでに送信済みなら二重送信しない
if resp, uErr := echo.UnwrapResponse(c.Response()); uErr == nil && resp.Committed {
return
}
// ① バリデーションエラー → 422(フィールド単位)
var ve validator.ValidationErrors
if errors.As(err, &ve) {
_ = c.JSON(http.StatusUnprocessableEntity, ErrorResponse{
Error: "validation failed",
Fields: toFieldErrors(ve),
})
return
}
// ② ステータス解決(HTTPError は HTTPStatusCoder を実装)
code := http.StatusInternalServerError
msg := "internal server error"
var sc echo.HTTPStatusCoder
if errors.As(err, &sc) {
if s := sc.StatusCode(); s != 0 {
code = s
}
}
var he *echo.HTTPError
if errors.As(err, &he) {
msg = he.Message // v5 は string
}
// ③ 5xx は詳細を隠し、相関 ID 付きでログへ
if code >= 500 {
c.Logger().Error("unhandled error",
"error", err.Error(),
"path", c.Request().URL.Path,
"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
)
msg = "internal server error" // クライアントには内部詳細を出さない
}
_ = c.JSON(code, ErrorResponse{Error: msg})
}
func main() {
e := echo.New()
e.HTTPErrorHandler = customHTTPErrorHandler
// ...
}
これで、各ハンドラはドメインのエラーを return するだけになり、HTTP の体裁・ログ・機微情報の遮断が一箇所に集約されます。公式も「エラーを Sentry / Elasticsearch / Splunk 等へ転送するなら、この集中ハンドラから」と推奨しています。
5.2 ドメインエラーを HTTP に対応づける
UseCase 層は HTTP を知るべきではありません。ドメイン固有のセンチネルエラーを定義し、集中ハンドラ(または薄い変換層)で HTTP ステータスに対応づけます。
// domain 層:HTTP を知らない
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already taken")
)
// エラーハンドラ側でマッピング(抜粋)
switch {
case errors.Is(err, ErrUserNotFound):
code, msg = http.StatusNotFound, "user not found"
case errors.Is(err, ErrEmailTaken):
code, msg = http.StatusConflict, "email already taken"
}
この分離により、ビジネスロジックは HTTP から独立してテスト可能なまま保てます(クリーンアーキテクチャ + DI)。クライアント⇄サーバー間でエラー形式まで型で合わせたい場合は、OpenAPI + Problem Details(RFC 9457)でスキーマ化する設計が有効です(Next.js × Go × OpenAPI)。
6. カスタム Binder:独自フォーマットを受ける
Protocol Buffers や独自の Content-Type、あるいは「JSON の数値を必ず厳格に解釈する」といった要件では、echo.Binder を自作して差し替えます。
type StrictBinder struct {
fallback echo.DefaultBinder
}
func (b *StrictBinder) Bind(c *echo.Context, i any) error { // v5 は (c, target) の順
if c.Request().Header.Get(echo.HeaderContentType) == "application/vnd.myapp+json" {
// 独自フォーマットの処理
return decodeStrict(c.Request().Body, i)
}
return b.fallback.Bind(c, i) // それ以外は標準バインダに委譲
}
func main() {
e := echo.New()
e.Binder = &StrictBinder{}
}
Bind の署名が Bind(c *echo.Context, i any)(v5 で引数順が反転)である点に注意してください。標準挙動を活かしたいケースが大半なので、DefaultBinder に委譲する形が安全です。
まとめ:境界設計の7原則
c.Bindはパス・クエリ・ボディを束ねる。ヘッダは対象外——BindHeadersを明示。- ビジネス構造体に直接 Bind しない。受け口専用 DTO で mass assignment を防ぐ。
- 入力 DTO と出力 DTO を分ける。機微フィールドを出力に載せない。
- 単一値は型安全に——
PathParam[T]/ Fluent バインダで変換失敗を 400 に。 - 検証は
go-playground/validatorをecho.Validatorに被せ、422 にフィールド単位で整形。 - エラーは握りつぶさず
return。集中ハンドラで整形・ログ・機微遮断を一箇所に集約。 - ドメインエラーは HTTP から独立させ、変換層でステータスに対応づける。
「動く API」と「本番で信用できる API」を分けるのは、まさにこの境界処理です。入力を型で殺し、エラーを一箇所で整形する——この型を持てば、Echo の API は格段に堅くなります。手前のミドルウェア設計と合わせ、全体像はGo Echo 本番運用ガイドに戻って俯瞰してください。