🦺

安心&簡単 Goとogenで始めるAPIサーバー開発

2024/09/30に公開

はじめに

Webアプリケーションをバックエンドとフロントエンドに分けて開発し、REST APIを利用する際、バックエンドとフロントエンド間で型情報の不一致や更新漏れが発生することがあります。これを解決するために、私たちはOpenAPIドキュメントでAPIを定義し、それに基づいてAPIを実装し、UIを構築する開発手法を採用しています。

導入してみたところ、非常に快適に感じたため記事にしてみました。
Go言語でOpenAPIドキュメントから自動的にコードを生成できるライブラリであるogenに焦点を当て、その導入手順と活用方法について詳しく解説します。ogenを利用することで、定義と実装の乖離を防ぎ、効率的かつ信頼性の高いAPIサーバーの開発が可能になります。

今回はogenの導入から使用例をコードを交えながら紹介します。次回の記事では、OpenTelemetryと組み合わせた利用方法についても解説する予定です。

コードはこちらにまとめて公開してあります。

https://github.com/RyoMiyashita/sample-go-ogen-otel

ogenの導入

ogenのインストール

goのプロジェクトがある前提で以下のコマンドで導入します。

go get -d github.com/ogen-go/ogen

OpenAPIドキュメントからのコード生成

ここでは以下の openapi.yaml を使用していきます。

https://github.com/RyoMiyashita/sample-go-ogen-otel/blob/main/openapi.yaml

上記 openapi.yaml を配置し、その横にogen.goを配置し、go generateでapi関連のファイルを生成してもらう準備をします。

ogen.go
package sample_ogen_otel

//go:generate go run github.com/ogen-go/ogen/cmd/ogen@latest --target logo --package logo --clean openapi.yaml

ファイルの準備ができたらルートディレクトリでgo generateを実行してみます。

go generate ./...

コマンドを実行すると、//go:generateのコメントを探してそのコマンドを実行してくれます。

コマンドの引数は以下のようなものです。

  • --targetは生成されたファイルを格納するディレクトリの指定
  • --packageは生成するコードのパッケージ名の指定
  • --cleanはコード生成するときに毎回一度コードを削除して、生成を行うように指定

以下のようなファイルが生成されるかと思います

├── logo
│   ├── oas_cfg_gen.go
│   ├── oas_client_gen.go
│   ├── oas_handlers_gen.go
│   ├── oas_json_gen.go
│   ├── oas_labeler_gen.go
│   ├── oas_middleware_gen.go
│   ├── oas_parameters_gen.go
│   ├── oas_request_decoders_gen.go
│   ├── oas_request_encoders_gen.go
│   ├── oas_response_decoders_gen.go
│   ├── oas_response_encoders_gen.go
│   ├── oas_router_gen.go
│   ├── oas_schemas_gen.go
│   ├── oas_server_gen.go
│   ├── oas_unimplemented_gen.go
│   └── oas_validators_gen.go

handlerの実装

こんな感じでhandlerが定義されるのでinterfaceを実装しましょう

https://github.com/RyoMiyashita/sample-go-ogen-otel/blob/82dcda3a5b394936a01a8cea96f98a4cb99d1ad8/logo/oas_server_gen.go#L9-L27

実装したハンドラー

https://github.com/RyoMiyashita/sample-go-ogen-otel/blob/82dcda3a5b394936a01a8cea96f98a4cb99d1ad8/main/server.go

リクエストボディやクエリパラメータが既に構造体として定義されているため、毎回のパース処理を実装する必要がないのは非常に助かります。

また、レスポンスも既に定義されているGoの構造体を返すだけで済みます。

serverのlisten

main関数などでサーバーを起動するようにしましょう。
生成されたコードにNewServerという関数があるのでそれを使用します。
http.Handlerを実装する形になっていますので、既存のrouterに入れるもよし、そのままhttp.ListenAndServeでサーバー起動するもよしです。

今回はswagger-uiでの確認もできるようにしたかったので以下のように/apiのハンドラーとして扱っています。

https://github.com/RyoMiyashita/sample-go-ogen-otel/blob/82dcda3a5b394936a01a8cea96f98a4cb99d1ad8/main/main.go

このようにハンドラーを引数に渡すだけでサーバーを作成できるのは便利ですね。

func newAPIRouter() (http.Handler, error) {
	service := NewLogoService()
	return logo.NewServer(service)
}

動作確認

mainを実行し、curlを投げて動作確認しましょう! swagger-uiでopenapi.yamlを表示してそこから操作するのも良さそうです。

1件 createした後にgetした結果は以下になります。定義通りにレスポンスが返ってきていることが確認できます。

curl -X 'GET' \
  'http://localhost:8080/api/logos' \
  -H 'accept: application/json' | jq

{
  "logos": [
    {
      "logoId": "319e92a4-93f7-4c1c-a1b1-5a68f30009dc",
      "name": "LOGOLABO",
      "createdAt": "2024-09-29T18:39:27+09:00",
      "updatedAt": "2024-09-29T18:39:27+09:00"
    }
  ],
  "totalCount": 1
}

ここまではogenでのAPIサーバーの簡単な実装でした!

エラーハンドリング

エラーの時のレスポンスはどうすればいいのでしょうか?
しれっと実装していましたが、詳しく解説していきます。

openapi.yamlで各APIのレスポンスを書くときにdefaultを定義し、そこでエラー時の挙動を定義していました。

openapi.yaml
      responses:
        '200':
          ~~~
        default:
          description: General error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

ogenでコード生成を行うとhandlerの中にNewError(ctx context.Context, err error) *logo.ErrorStatusCodeというメソッドが定義されていたかと思います。

他のAPIの処理ではerrorを返すだけになっていますので、エラー時はerrorを返すだけです。error != nilの時にこのNewErrorメソッドが呼ばれる形になります。

ここでは手抜き実装ですが、カスタムエラーを定義してあげて404や、401などを返せるようにしてあげれば良さそうです。

server.go
func (l *LogoService) NewError(ctx context.Context, err error) *logo.ErrorStatusCode {
	slog.ErrorContext(ctx, "detect api error", "err", err.Error())
	return &logo.ErrorStatusCode{
		StatusCode: 500, // TODO: error code
		Response: logo.Error{
			Code:    500,
			Message: err.Error(),
		},
	}
}

認証

APIに認証はつけておきたいですよね!認証の実装を追加していきましょう。
JWTのトークンを使った簡単な認証を実装していきます。

コードの修正はこのPRが参考になります。

https://github.com/RyoMiyashita/sample-go-ogen-otel/pull/3/files

認証の定義を追加

openapi.yamlに認証の定義を追加していきましょう。
まずは認証の種類の定義です。今回はbearerを使ったtoken認証なので以下のように定義します。

openapi.yaml
components:
~~~
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

それぞれのAPIに認証を追加していきます。

    post:
      tags:
        - logo
      summary: Create a new logo
      operationId: createLogo
      ~~~
+     security:
+       - bearerAuth: [ ]

コード生成

再度 go generate ./...を実行して生成されたコードを見てみましょう。

handlerを生成する時に作っていたNewServer関数に変更が加えられているのが確認できるかとお思います。

oas_server_gen.go
 // NewServer creates new Server.
-func NewServer(h Handler, opts ...ServerOption) (*Server, error) {
+func NewServer(h Handler, sec SecurityHandler, opts ...ServerOption) (*Server, error) {
        s, err := newServerConfig(opts...).baseServer()
        if err != nil {
                return nil, err
        }

SecurityHandlerといういかにもなHandlerを引数に持つようになりました。

SecurityHandlerは以下のようなinterfaceでHandleBearerAuthメソッドを実装してあげればいいです。

oas_security_gen.go
// SecurityHandler is handler for security parameters.
type SecurityHandler interface {
	// HandleBearerAuth handles bearerAuth security.
	HandleBearerAuth(ctx context.Context, operationName string, t BearerAuth) (context.Context, error)
}

認証の実装

JWT(JSON Web Token)の扱いは省略しますが、上記のインターフェースを実装するだけです。
BearerAuthという構造体の引数があるのでそこからtokenを受け取り、jwtの確認をしてあげればOKです。実際にはここでJWTの中からクレームを取り出して、ctxにセットするなどの処理が追加されることでしょう。

https://github.com/RyoMiyashita/sample-go-ogen-otel/blob/main/main/auth.go

ついでにtokenを発行するAPIも作成しておきます。

openapi.yaml
paths:
  /token:
    post:
      tags:
        - auth
      summary: Get a token
      operationId: getToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        default:
          description: General error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
openapi.yaml
components:
  schemas:
    TokenRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          description: The email of the user
    TokenResponse:
      type: object
      required:
        - token
      properties:
        token:
          type: string
          description: The token
    LogoSearchResult:
      type: object
      required:
        - logos
        - totalCount
      properties:
        logos:
          type: array
          items:
            $ref: '#/components/schemas/LogoDetail'
          description: The logos
        totalCount:
          type: integer
          description: The total count of logos

https://github.com/RyoMiyashita/sample-go-ogen-otel/blob/bbbd0fc7f32939817c276cf12e831c5e8d1b31dd/main/server.go#L34-L55

動作確認

まずは、tokenなしでリクエストを投げてみましょう。エラー処理が簡略化されているため500エラーになってしまいますが、ここでは無視してください。

curl -X 'GET' \               
  'http://localhost:8080/api/logos' \
  -H 'accept: application/json' | jq

{
  "code": 500,
  "message": "operation GetLogoList: security \"\": security requirement is not satisfied"
}

ちゃんとエラーになりましたね!
では、トークンを発行し、それを付与してリクエストを投げてみましょう。

curl -X 'GET' \
  'http://localhost:8080/api/logos' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InIubWl5YXNoaXRhQGV4YW1wbGUuY29tIiwiZXhwIjoxNzI3NzA5NTQxLCJpc3MiOiJsb2dvIn0.BNCDDOs0plaSVTM0xIB-4PE1kfAlZ_fNVTvqWdNusa0' | jq

{
  "logos": [
    {
      "logoId": "795ee99c-7a66-4d68-bfdb-a72a81ac9fc0",
      "name": "token test",
      "createdAt": "2024-09-30T00:34:57+09:00",
      "updatedAt": "2024-09-30T00:34:57+09:00"
    }
  ],
  "totalCount": 1
}

トークンを付与すると、正しく値を取得することができました!

まとめ

ここまで、ogenを利用してOpenAPIベースでのAPIサーバー開発の導入を行ってみました。意外と簡単に実装できたのではないでしょうか。フロントエンド担当者と「型が定義と違う!」といったトラブルがなくなるのは非常に良い点です。
開発するコードが減るため、実装を簡略化できる点でも有効だと感じます。

他にもさまざまな機能があるので、ぜひ確認してみてください〜〜

今後、OpenTelemetryを導入してトレース情報を取得できるようにしたり、フロントエンド側での対応についても記事にしていく予定です!

Discussion