😇

Huma で Go の Web API 開発をもっとスマートに!

2024/12/10に公開

はじめに

前回の記事では、Goの主要なWebフレームワーク Gin、Fiber、Echo、Chiを比較しました。今回は、それらのフレームワークを土台にして、さらにパワフルなAPIを構築できるHumaというフレームワークを紹介します!

以下は、前回記事です。
https://zenn.dev/sonicmoov/articles/3b712b07f722c9

Humaは、OpenAPI 3仕様を駆使してAPIを設計・構築するためのフレームワークです。まるで設計図を基に家を建てるように、APIを構築していくイメージですね。

Pythonistaの皆さんにも朗報です!Humaは、PythonのFastAPIのように、OpenAPI 3ベースのAPIを構築できます。FastAPIのような快適な開発体験をGoで求めるならHuma一択です。

https://huma.rocks/

Humaがもたらす革新

  • OpenAPI 3ベース: OpenAPI 3仕様に準拠したAPIを定義することで、APIの構造化と標準化を促進します。
  • APIドキュメントの自動生成: OpenAPI 3の定義からAPIドキュメントが自動生成されます。開発者ポータルも簡単に作れますね!
  • リクエストバリデーション: OpenAPI 3のスキーマ定義に基づいて、リクエストのバリデーションを自動的に行います。不正なデータはシャットアウト!
  • 型安全なコード: OpenAPI 3の定義から、型安全にコードを書く事ができます。
  • 複数のフレームワークに対応: Gin、Fiber、Echo、Chi などのフレームワークをルーターとして利用できます。

HumaでTodo APIを構築!

前回作成したTodo APIに、更新と削除の機能を追加してみましょう。

サンプルコードはこちらのリポジトリで公開しています。
https://github.com/takemo101/golang-huma-todo

プロジェクト構成

.
├── 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のルーティングを定義します。

main.go
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と入力データを受け取り、出力データとエラーを返す関数として定義します。

main.go
// 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が用意されており、非常に簡単かつエレガントにテストが書けます。

main_test.go
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で動かすためのユーティリティで、コマンド引数が設定できたり、フック処理を追加できたりするので便利です。

main.go
// 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