🐈

GoのAPI開発がさらに進化!OpenAPI層「huma」の実践的な魅力(第二弾)

に公開

「humaの基本的な使い方は分かったけど、実際のプロダクトで使うにはどうなの?」

前回の記事では、GinやChiに乗せるOpenAPI層として、humaの基本的な立ち位置やgin-swaggerとの違い、そしてベンチマーク比較をご紹介しました。

https://zenn.dev/nonejp/articles/7542745cd01b1f

前回は「既存のルーターに被せるだけで、構造体からOpenAPIドキュメントが自動生成される」という基本的な使い方を解説しました。しかし、humaの魅力はドキュメント生成だけではありません。

実際のAPI開発で必ず直面する「複雑なバリデーション」「統一されたエラーハンドリング」「ミドルウェアとの連携」、そして「テストの書きやすさ」において、humaは非常に強力な機能を提供してくれます。

今回は、humaを実戦投入する上で欠かせない、よりディープで実践的な機能たちを、「素のGin/Chiで書いた場合」との比較 を交えながらたっぷりとお届けします!


1. 複雑なバリデーションをスッキリ書ける「Resolvers」

前回、構造体タグ(maxLengthpatternなど)を使って基本的なバリデーションを自動で行う方法を紹介しました。しかし、実際の開発では「DBに問い合わせて存在チェックをしたい」「複数のフィールドを組み合わせた相関チェックをしたい」といったケースが必ず発生します。

素のGin/Chiのアプローチ(ハンドラー内でのバリデーション)

通常、GinやChiで複雑なバリデーションを行う場合、ハンドラー(コントローラー)の中にロジックを書くことになります。

// Ginでの例
func CreateUser(c *gin.Context) {
    var input CreateUserInput
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // ハンドラー内にバリデーションロジックが混在してしまう
    if strings.HasSuffix(input.Email, "@example.com") {
        c.JSON(422, gin.H{"error": "example.comドメインは使用できません"})
        return
    }

    // 名前のフォーマット変換
    input.Name = strings.Title(input.Name)

    // 本来のビジネスロジック...
}

これでは、ハンドラーが肥大化し、本来のビジネスロジックが見えにくくなってしまいます。

huma のアプローチ(Resolvers)

humaでは、Resolvers(リゾルバー) という仕組みを使います。リクエストの構造体に Resolve メソッドを実装するだけで、humaが自動的にそれを呼び出し、カスタムバリデーションやデータの変換を行ってくれます。

// 入力モデル
type CreateUserInput struct {
    Name  string `json:"name" maxLength:"30"`
    Email string `json:"email" format:"email"`
}

// Resolveメソッドを実装してカスタムバリデーションを追加
func (i *CreateUserInput) Resolve(ctx huma.Context) []error {
    // 名前のフォーマット変換(例:先頭大文字)
    i.Name = strings.Title(i.Name)

    // カスタムバリデーション(例:特定のドメインを弾く)
    if strings.HasSuffix(i.Email, "@example.com") {
        return []error{&huma.ErrorDetail{
            Message:  "example.comドメインは使用できません",
            Location: "body.email",
            Value:    i.Email,
        }}
    }

    return nil
}

// インターフェース実装チェック(コンパイル時に検証)
var _ huma.Resolver = (*CreateUserInput)(nil)

// ハンドラーはビジネスロジックに集中できる!
huma.Post(api, "/users", func(ctx context.Context, input *CreateUserInput) (*UserOutput, error) {
    // ここに到達した時点で、バリデーションと変換は全て完了している
    createUser(input.Name, input.Email)
    return &UserOutput{}, nil
})

このアプローチの素晴らしいところは、ハンドラーの中にバリデーションロジックが一切散らからない ことです。ハンドラーにリクエストが到達した時点では、すでに全てのバリデーションと変換が完了していることが保証されます。


2. RFC 9457準拠の美しいエラーハンドリング

APIを開発する際、エラーレスポンスのフォーマットをどうするかは悩みの種です。

素のGin/Chiのアプローチ(独自フォーマット)

多くの場合、プロジェクトごとに独自のエラーJSONフォーマットを定義し、それを返すヘルパー関数を作ることになります。

// Ginでの例
func GetUser(c *gin.Context) {
    user, err := findUser(c.Param("id"))
    if err != nil {
        // 独自フォーマットでエラーを返す
        c.JSON(404, gin.H{
            "code": "USER_NOT_FOUND",
            "message": "ユーザーが見つかりません",
        })
        return
    }
    c.JSON(200, user)
}

これでも動きますが、API全体でフォーマットを統一する手間がかかり、クライアント側もAPIごとにエラーのパース処理を書く必要があります。

huma のアプローチ(RFC 9457準拠)

humaはデフォルトで RFC 9457 (Problem Details for HTTP APIs) に準拠したエラーレスポンスを返してくれます。これはHTTP APIのエラー表現の標準仕様です。

ハンドラー内でエラーを返す場合も、humaが用意しているユーティリティ関数を使うだけで、適切なステータスコードとフォーマットでレスポンスが生成されます。

huma.Get(api, "/users/{id}", func(ctx context.Context, input *UserInput) (*UserOutput, error) {
    user, err := findUser(input.ID)
    if err != nil {
        // 404 Not Foundエラーを返す
        return nil, huma.Error404NotFound("ユーザーが見つかりません", err)
    }
    // ...
})

このコードが返すエラーレスポンスは、以下のような美しいJSONになります。

{
  "$schema": "https://api.example.com/schemas/ErrorModel.json",
  "status": 404,
  "title": "Not Found",
  "detail": "ユーザーが見つかりません"
}

バリデーションエラーの場合は、さらに errors 配列に詳細な情報(どのフィールドが、どういう理由でエラーになったか)が自動的に追加されます。もちろん、独自のカスタムエラーモデルを定義して、組織の標準フォーマットに合わせることも可能です。


3. ミドルウェアとグループ機能の強力な連携

前回はChiルーターにhumaを被せる例を紹介しましたが、humaは「ルーター非依存」であるため、GinやChiの既存ミドルウェアをそのまま使うことができます。それに加えて、huma独自の「ルーター非依存ミドルウェア」も定義可能です。

素のGin/Chiのアプローチ(ルーター依存のグループ化)

GinやChiでもグループ化やミドルウェアの適用は簡単です。

// Ginでの例
v1 := engine.Group("/api/v1")
v1.Use(AuthMiddleware())
{
    v1.GET("/users", getUsers)
}

しかし、これだけでは「OpenAPIドキュメント上のタグ(グループ分け)」までは連動しません。ドキュメント上でもグループ化するには、gin-swagger のコメントを各ハンドラーに手書きする必要があります。

huma のアプローチ(Groups機能)

humaの Groups(グループ) 機能を使えば、特定のパスプレフィックスを持つAPI群に対して、まとめてミドルウェアを適用したり、OpenAPIのタグを設定したりできます。

// /api/v1 プレフィックスを持つグループを作成
v1 := huma.NewGroup(api, "/api/v1")

// グループ全体に認証ミドルウェアを適用
v1.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
    token := ctx.Header("Authorization")
    if token == "" {
        huma.WriteErr(api, ctx, http.StatusUnauthorized, "認証が必要です")
        return
    }
    // 認証成功なら次の処理へ
    next(ctx)
})

// グループ全体にOpenAPIのタグを設定(ドキュメントに反映される!)
v1.UseSimpleModifier(func(op *huma.Operation) {
    op.Tags = []string{"V1 API"}
})

// グループにエンドポイントを登録
huma.Get(v1, "/users", getUsersHandler)

これにより、APIのバージョン管理や、認証が必要なエンドポイントの切り分けが非常にクリーンに実装でき、それが自動的にOpenAPIドキュメントにも反映されます。


4. 痒い所に手が届く「Auto PATCH」機能

REST APIを設計する際、リソースの部分更新(PATCH)を実装するのは意外と面倒です。

素のGin/Chiのアプローチ(手動実装)

通常、PATCHエンドポイントを実装するには、リクエストボディのJSONをパースし、指定されたフィールドだけを更新するロジックを自前で書く必要があります。

// Ginでの例(非常に面倒)
func PatchUser(c *gin.Context) {
    var updates map[string]interface{}
    if err := c.ShouldBindJSON(&updates); err != nil {
        // ...
    }
    
    // updatesの中身を見て、DBの更新クエリを動的に組み立てる...
}

huma のアプローチ(Auto PATCH)

humaには、なんと PATCHエンドポイントを自動生成してくれる autopatch パッケージが存在します。

GETとPUTのエンドポイントが存在する場合、サーバー起動時に自動的にPATCHエンドポイントが生成され、以下のフォーマットに対応してくれます。

  • JSON Merge Patch (application/merge-patch+json)
  • JSON Patch (application/json-patch+json)
import "github.com/danielgtaylor/huma/v2/autopatch"

// GETとPUTを登録
huma.Get(api, "/users/{id}", getUser)
huma.Put(api, "/users/{id}", putUser)

// 最後にAutoPatchを呼び出すだけで、PATCHエンドポイントが自動生成される!
autopatch.AutoPatch(api)

クライアント側から柔軟な部分更新を行いたい場合に、実装コストを劇的に下げてくれる魔法のような機能です。


5. テストが書きやすい!専用の humatest パッケージ

APIのテストを書く際、HTTPリクエストをモックしてレスポンスを検証するコードは冗長になりがちです。

素のGin/Chiのアプローチ(httptestパッケージ)

標準の httptest パッケージを使うと、リクエストの組み立てやJSONのシリアライズ/デシリアライズを手動で行う必要があります。

// 標準的なテストの例
func TestGetUser(t *testing.T) {
    router := setupRouter()
    
    req, _ := http.NewRequest("GET", "/users/123", nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)

    assert.Equal(t, 200, w.Code)
    // レスポンスボディの検証も面倒...
}

huma のアプローチ(humatestパッケージ)

humaはテスト用のユーティリティパッケージ humatest を提供しており、直感的にテストを記述できます。

import (
    "testing"
    "github.com/danielgtaylor/huma/v2/humatest"
    "github.com/stretchr/testify/assert"
)

func TestGetUser(t *testing.T) {
    // テスト用のAPIインスタンスを作成
    _, api := humatest.New(t)
    
    // ルーティングを登録
    huma.Get(api, "/users/{id}", getUserHandler)

    // GETリクエストを送信
    resp := api.Get("/users/123")

    // レスポンスの検証
    assert.Equal(t, 200, resp.Code)
    assert.Contains(t, resp.Body.String(), "John Doe")
}

api.Get()api.Post() といったメソッドが用意されており、リクエストボディもGoの map や構造体を渡すだけで自動的にJSONにシリアライズしてくれます。テストコードの可読性が格段に上がりますね。


6. 気になるパフォーマンス(ベンチマーク実測)

「humaが便利なのは分かったけど、間に層を挟む分、パフォーマンスが落ちるのでは?」と心配になる方もいるでしょう。

そこで、実際にUbuntu環境(Intel Xeon 2.50GHz)でベンチマークを計測してみました。

計測条件

  • Go 1.25.0
  • benchtime=5s, count=3
  • 単純なGETリクエスト(パスパラメータのパースとJSONレスポンス)
フレームワーク 処理時間 (ns/op) メモリ割当 (B/op) アロケーション回数
Gin(素) 6,314 ns 6,632 B 27 回
huma + Gin 8,097 ns 6,421 B 28 回
Chi(素) 5,689 ns 6,855 B 23 回
huma + Chi 8,941 ns 7,134 B 32 回

※数値は3回の計測の平均値

結果の考察

  • オーバーヘッドは数マイクロ秒レベル: 素のGin/Chiと比較して、humaを挟むことで約28%〜57%(約1.7〜3.2マイクロ秒)のオーバーヘッドが発生します。
  • 実用上は誤差: このオーバーヘッドは、実際のAPI開発においてDBへのクエリや外部APIの呼び出し(数ミリ秒〜数十ミリ秒)が発生することを考えると、完全に誤差の範囲です。
  • メモリ効率は優秀: メモリ割り当て量(B/op)に関しては、humaを挟んでもほとんど増加せず、むしろGinの場合はわずかに減少する結果となりました。

結論として、「パフォーマンスを犠牲にすることなく、型安全と自動ドキュメント生成の恩恵をフルに受けられる」 と言って良いでしょう。


まとめ

前回の「OpenAPI層としての基本機能」に加えて、今回はhumaの実践的な機能をご紹介しました。

機能 素のGin/Chiでの課題 humaでの解決策
バリデーション ハンドラー内にロジックが混在し肥大化する Resolvers で構造体にカプセル化。ハンドラーを汚さない
エラーハンドリング 独自フォーマットの統一が面倒 RFC 9457準拠 の美しいエラーJSONを自動生成
グループ化 ドキュメントのタグ付けと連動しない Groups でミドルウェアとOpenAPIタグをまとめて管理
部分更新 PATCHの実装が非常に面倒 Auto PATCH でGETとPUTから自動生成
テスト httptest の組み立てが冗長 humatest で直感的で短いコードで結合テストが書ける

humaは単なる「Swagger生成ツール」ではなく、GoでのAPI開発体験全体をモダンに引き上げてくれる強力なフレームワーク です。

GinやChiといった使い慣れたルーターの良さを活かしつつ、FastAPIのような宣言的で型安全な開発体験を手に入れたい方は、ぜひ次のプロジェクトでhumaを試してみてください!

参考リンク

VeriCerts Tech Blog

Discussion