Huma で Go の Web API 開発をもっとスマートに!
はじめに
前回の記事では、Goの主要なWebフレームワーク Gin、Fiber、Echo、Chiを比較しました。今回は、それらのフレームワークを土台にして、さらにパワフルなAPIを構築できるHumaというフレームワークを紹介します!
以下は、前回記事です。
Humaは、OpenAPI 3仕様を駆使してAPIを設計・構築するためのフレームワークです。まるで設計図を基に家を建てるように、APIを構築していくイメージですね。
Pythonistaの皆さんにも朗報です!Humaは、PythonのFastAPIのように、OpenAPI 3ベースのAPIを構築できます。FastAPIのような快適な開発体験をGoで求めるならHuma一択です。
Humaがもたらす革新
- OpenAPI 3ベース: OpenAPI 3仕様に準拠したAPIを定義することで、APIの構造化と標準化を促進します。
- APIドキュメントの自動生成: OpenAPI 3の定義からAPIドキュメントが自動生成されます。開発者ポータルも簡単に作れますね!
- リクエストバリデーション: OpenAPI 3のスキーマ定義に基づいて、リクエストのバリデーションを自動的に行います。不正なデータはシャットアウト!
- 型安全なコード: OpenAPI 3の定義から、型安全にコードを書く事ができます。
- 複数のフレームワークに対応: Gin、Fiber、Echo、Chi などのフレームワークをルーターとして利用できます。
HumaでTodo APIを構築!
前回作成したTodo APIに、更新と削除の機能を追加してみましょう。
サンプルコードはこちらのリポジトリで公開しています。
プロジェクト構成
.
├── main.go
├── main_test.go
├── repository
│ └── todos.go # 永続化処理
└── shared
└── token.go # トークン検証処理
main.go
でルーティングを定義し、repository
で永続化処理、shared
で共通処理を定義します。
実装
1. プロジェクトの初期化
以下のコマンドでプロジェクトを初期化します。
# 前回と同じくプロジェクト名はappとしています
go mod init app
2. パッケージのインストール
Humaと、ルーターとして使用するGin、そしてテスト用のパッケージをインストールします。
go get -u github.com/danielgtaylor/huma/v2
# 今回はルーターにGinを使用するので、Ginもインストールします
go get github.com/gin-gonic/gin
# テスト用のパッケージもインストールします
go get github.com/stretchr/testify
3. ルーティング定義
Humaでは、huma.Register
関数を使ってAPIのルーティングを定義します。
func setupRoutes(api huma.API) {
api.UseMiddleware(createTokenAuth(api))
// Humaでは、他のフレームワークのようなグルーピングができない模様なので、ルートのフルパスを指定する必要がある
// 一覧取得
huma.Register(api, huma.Operation{
OperationID: "getTodos",
Method: http.MethodGet,
Path: "/api/v1/todos",
Summary: "Todo一覧を取得",
Tags: []string{"todos"},
Security: []map[string][]string{
{"queryToken": {}},
},
}, getTodos)
// 詳細取得
huma.Register(api, huma.Operation{
OperationID: "getTodo",
Method: http.MethodGet,
Path: "/api/v1/todos/{id}",
Summary: "Todo詳細を取得",
Tags: []string{"todos"},
Security: []map[string][]string{
{"queryToken": {}},
},
}, getTodoById)
// 作成
huma.Register(api, huma.Operation{
OperationID: "createTodo",
Method: http.MethodPost,
Path: "/api/v1/todos",
Summary: "Todoを作成",
Tags: []string{"todos"},
DefaultStatus: http.StatusCreated,
Security: []map[string][]string{
{"queryToken": {}},
},
}, createTodo)
// 更新
huma.Register(api, huma.Operation{
OperationID: "updateTodo",
Method: http.MethodPut,
Path: "/api/v1/todos/{id}",
Summary: "Todoを更新",
Tags: []string{"todos"},
Security: []map[string][]string{
{"queryToken": {}},
},
}, updateTodo)
// 削除
huma.Register(api, huma.Operation{
OperationID: "deleteTodo",
Method: http.MethodDelete,
Path: "/api/v1/todos/{id}",
Summary: "Todoを削除",
Tags: []string{"todos"},
DefaultStatus: http.StatusNoContent,
Security: []map[string][]string{
{"queryToken": {}},
},
}, deleteTodoById)
}
4. ミドルウェア/リクエストハンドラ
リクエストハンドラはcontext.Context
と入力データを受け取り、出力データとエラーを返す関数として定義します。
// Humaでは、リクエストやレスポンスのスキーマ情報を型定義して利用します
// Todo出力のBodyデータ定義
type TodoBody struct {
ID string `json:"id" example:"1" doc:"TodoのID"`
Title string `json:"title" example:"XXXに連絡する" doc:"Todoのタイトル"`
Completed bool `json:"completed" example:"false" doc:"Todoの完了状態"`
}
// Todo詳細取得のレスポンスデータ定義
type TodoOutput struct {
Body struct {
Todo TodoBody `json:"todo" doc:"Todoの詳細"`
}
}
// Todo一覧取得のレスポンスデータ定義
type TodosOutput struct {
Body struct {
Todos []TodoBody `json:"todos" doc:"Todoの一覧"`
}
}
// Todo入力のBodyデータ定義
type TodoInputBody struct {
Title string `json:"title" minLength:"1" maxLength:"100" example:"XXXに連絡する" doc:"Todoのタイトル"`
Completed bool `json:"completed" example:"false" doc:"Todoの完了状態"`
}
// Todo作成のリクエストデータ定義
type CreateTodoInput struct {
Body TodoInputBody
}
// Todo更新のリクエストデータ定義
type UpdateTodoInput struct {
ID string `path:"id" required:"true" doc:"TodoのID"`
Body TodoInputBody
}
// huma.APIを引数に取り、ミドルウェアを返す関数を定義
func createTokenAuth(api huma.API) func(huma.Context, func(huma.Context)) {
// ミドルウェアはシンプルな関数で実装できる
return func(ctx huma.Context, next func(ctx huma.Context)) {
token := ctx.Query("token")
// トークンが一致しない場合は 401 を返す
if shared.IsInvalidToken(token) {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized", fmt.Errorf("Invalid token"))
return
}
next(ctx)
}
}
// リクエスト入力とレスポンス出力の型を定義することで、
// 型情報をHumaのAPIドキュメントに反映することができる
func getTodos(_ context.Context, _ *struct{}) (*TodosOutput, error) {
res := &TodosOutput{}
todos := repository.GetTodos()
for _, t := range todos {
res.Body.Todos = append(res.Body.Todos, TodoBody{
ID: t.ID,
Title: t.Title,
Completed: t.Completed,
})
}
return res, nil
}
func getTodoById(_ context.Context, input *struct {
ID string `path:"id" required:"true" doc:"TodoのID"`
}) (*TodoOutput, error) {
res := &TodoOutput{}
t, ok := repository.GetTodoById(input.ID)
if !ok {
return nil, huma.Error404NotFound("Todo not found")
}
res.Body.Todo = TodoBody{
ID: t.ID,
Title: t.Title,
Completed: t.Completed,
}
return res, nil
}
func createTodo(_ context.Context, input *CreateTodoInput) (*TodoOutput, error) {
res := &TodoOutput{}
todo := repository.CreateTodo(repository.TodoForCreateOrUpdate{
Title: input.Body.Title,
Completed: input.Body.Completed,
})
res.Body.Todo = TodoBody{
ID: todo.ID,
Title: todo.Title,
Completed: todo.Completed,
}
return res, nil
}
func updateTodo(_ context.Context, input *UpdateTodoInput) (*TodoOutput, error) {
res := &TodoOutput{}
todo, ok := repository.UpdateTodo(input.ID, repository.TodoForCreateOrUpdate{
Title: input.Body.Title,
Completed: input.Body.Completed,
})
if !ok {
return nil, huma.Error404NotFound("Todo not found")
}
res.Body.Todo = TodoBody{
ID: todo.ID,
Title: todo.Title,
Completed: todo.Completed,
}
return res, nil
}
func deleteTodoById(_ context.Context, input *struct {
ID string `path:"id" required:"true" doc:"TodoのID"`
}) (*struct{}, error) {
if !repository.DeleteTodoById(input.ID) {
return nil, huma.Error404NotFound("Todo not found")
}
return nil, nil
}
5. テストコード
huma
ではテストのユーティリティであるhumatest
が用意されており、非常に簡単かつエレガントにテストが書けます。
package main
// import文は省略
func TestRoutes(t *testing.T) {
_, api := humatest.New(t)
setupRoutes(api)
t.Run("無効なトークンのテスト", func(t *testing.T) {
res := api.Get("/api/v1/todos?token=invalid")
assert.Equal(t, 401, res.Code)
})
t.Run("Todo一覧取得のテスト", func(t *testing.T) {
res := api.Get("/api/v1/todos?token=" + shared.Token)
assert.Equal(t, 200, res.Code)
})
t.Run("Todo詳細取得のテスト", func(t *testing.T) {
res := api.Get("/api/v1/todos/first?token=" + shared.Token)
assert.Equal(t, 200, res.Code)
})
t.Run("Todo作成のテスト", func(t *testing.T) {
data := map[string]any{
"title": "test",
"completed": false,
}
res := api.Post("/api/v1/todos?token="+shared.Token, data)
assert.Equal(t, 201, res.Code)
var body struct {
Todo TodoBody `json:"todo"`
}
// レスポンスの body をパースして Todo を取得
json.NewDecoder(res.Body).Decode(&body)
assert.Equal(t, data["title"], body.Todo.Title)
assert.Equal(t, data["completed"], body.Todo.Completed)
})
t.Run("Todo更新のテスト", func(t *testing.T) {
data := map[string]any{
"title": "test",
"completed": true,
}
res := api.Put("/api/v1/todos/first?token="+shared.Token, data)
assert.Equal(t, 200, res.Code)
var body struct {
Todo TodoBody `json:"todo"`
}
// レスポンスの body をパースして Todo を取得
json.NewDecoder(res.Body).Decode(&body)
assert.Equal(t, data["title"], body.Todo.Title)
assert.Equal(t, data["completed"], body.Todo.Completed)
})
t.Run("Todo削除のテスト", func(t *testing.T) {
res := api.Delete("/api/v1/todos/first?token=" + shared.Token)
assert.Equal(t, 204, res.Code)
})
}
6. サーバー起動
Humaは、humacli
パッケージを使ってサーバーを起動します。humacli
は、サービスをCLIで動かすためのユーティリティで、コマンド引数が設定できたり、フック処理を追加できたりするので便利です。
// Options はコマンドライン引数を格納するための構造体
type Options struct {
Port int `help:"Port to listen on" short:"p" default:"8888"`
Hostname string `help:"Hostname to listen on" short:"n" default:"localhost"`
}
func main() {
cli := humacli.New(func(hooks humacli.Hooks, options *Options) {
// Humaginアダプターを利用することで、HumaでGinを利用したAPIを作成できる
engine := gin.Default()
config := huma.DefaultConfig("Todo API", "1.0.0")
// セキュリティスキーム(クエリパラメータでトークンを検証)を定義
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"queryToken": {
Type: "apiKey",
In: "query",
Name: "token",
},
}
api := humagin.New(engine, config)
setupRoutes(api)
// そのままGinを利用してルートを追加することもできるが、
// このルートはHumaのAPIドキュメントには反映されない
engine.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(200, gin.H{
"message": "pong",
})
})
// サーバー起動時の処理をフックに登録
hooks.OnStart(func() {
engine.Run(fmt.Sprintf("%s:%d", options.Hostname, options.Port))
})
})
cli.Run()
}
実行
実装が完了したら、以下のコマンドでサーバーを起動します。
go run main.go -p 8080 -n localhost
上記を実行すると、localhost:8080
でサーバーが起動します。
以下のようにhttp://localhost:8080/api/v1/todos?token=token
にアクセスすると、JSON形式でデータが返ってきます。
curl -X GET "http://localhost:8080/api/v1/todos?token=token"
APIドキュメントの自動生成
Humaの素晴らしい機能の一つに、 APIドキュメントの自動生成 があります。go run main.go
を実行している状態で、http://localhost:8080/docs
にアクセスすると、以下のような美しいAPIドキュメントが表示されます(感動!)
まとめ
今回は、Todo APIを通してHumaの基本的な使い方を紹介しました。Humaは、OpenAPI 3仕様を基盤としたAPI開発を強力にサポートしてくれるフレームワークです。ぜひ、皆さんのプロジェクトでもHumaを活用してみてください!
Discussion