スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

oapi-codegen の strict-server オプションを使ってより硬くサーバーサイドを実装する

スタディサプリ小中高でエンジニアリングマネージャーやソフトウェアエンジニアをしている @pankona です。

本稿では oapi-codegen の strict-server オプションを使った開発事例を紹介します。

OpenAPI と oapi-codegen

OpenAPI とは、Web API の仕様を記述するための規格のひとつです。どのパスがどんなリクエストを受け付けてどんなレスポンスを返すのか、という情報を yaml もしくは json の形式で記述することができます。以下は公式サイトです。2024年12月現在、OpenAPI 3.1.0 が最新バージョンのようです。

swagger.io

OpenAPI の規格にそって API の仕様を記述しておくと、API を実装中あるいは運用の際に、たとえば以下のようなメリットがあります。

  • yaml ã‚„ json はそのままだと若干人間には読みにくいですが、Swagger UI のようなツールを使って人間にも見やすい仕様書の形式に変換することができます。
  • クライアントサイドのコードを自動生成することで、API 呼び出しのための実装を省略できます。型がある言語であれば型安全に API 呼び出しをすることができるようになります。
  • サーバーサイドのコードを自動生成することで、リクエストとレスポンスの型を実装する部分を省略できます。
  • API の仕様に変更があって仕様の記載を変更したときも、コードを自動生成することによって変更に追従することが容易です。

コード生成のためのツールのひとつとして、oapi-codegen があります。

github.com

スタディサプリを支えているマイクロサービスのうち、Go で書かれていて、かつ oapi-codegen によるコード生成を用いているサーバーアプリがそれなりに存在します。特に直近で作られているものについては oapi-codegen のオプションのひとつである strict-server オプションを用いてコード生成されているものが増えています (私が推しています) 。

oapi-codegen の strict-server オプションを使うと何が嬉しいのか

コードサンプル

たとえば、以下のような yaml を書いたとします。これは「足跡の一覧を取得する」という架空の API です。足跡というのはゲストブック1のことです。

paths:
  /ashiatos:
    get:
      summary: Get Ashiato List
      operationId: getAshiatoList
      parameters:
        - name: page
          in: query
          required: true
          schema:
            type: integer
        - name: per_page
          in: query
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GetAshiatoListResponse"
        "400":
          description: Bad Request
        "500":
          description: Internal Server Error

# 中略
components:
  schemas:
    GetAshiatoListResponse:
      type: object
      properties:
        total_count:
          type: integer
        per_page:
          type: integer
        page:
          type: integer
        ashiato_list:
          type: array
          items:
            $ref: "#/components/schemas/Ashiato"
      required:
        - total_count
        - per_page
        - page
        - ashiato_list

何が書かれているかをざっくり書くと、以下の三点です。

  • 成功時には HTTP Status Code 200 と共に GetAshiatoListResponse を返す
  • 引数が正しくない場合は HTTP Status Code 400 を返す
  • それ以外のエラーが起きたときは HTTP Status Code 500 を返す

これを元に oapi-codegen を用いてサーバーのためのコードを生成すると、リクエストやレスポンスの型、バリデーション等が色々生成されます。生成は以下のようなコマンドで行います (先述の yaml ファイルを openapi.yml、生成するファイルを generated_types.go であると仮定しています) 。

go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
oapi-codegen -generate strict-server,types,chi-server,spec -package openapi openapi.yml > generated_types.go

自動生成されたもののうち、ここでは以下の interface に注目します。

// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
    // Get Ashiato List
    // (GET /ashiatos)
    GetAshiatoList(ctx context.Context, request GetAshiatoListRequestObject) (GetAshiatoListResponseObject, error)
}

StrictServerInterface は、oapi-codegen を用いてコード生成を行う際に strict-server オプションを付与すると生成される interface です。この interface に定義されている関数を実装することで、サーバーアプリを作っていきます。

interface の中身を見てみると、GetAshiatoList という関数が定義されています。この関数は引数に GetAshiatoListRequestObject をとり、戻り値として GetAshiatoListResponseObject を返却するような関数です。これらの構造体も oapi-codegen によって自動生成されています。構造体は以下のように定義されています。

type GetAshiatoListRequestObject struct {
    Params GetAshiatoListParams
}

// GetAshiatoListParams defines parameters for GetAshiatoList.
type GetAshiatoListParams struct {
    Page    int `form:"page" json:"page"`
    PerPage int `form:"per_page" json:"per_page"`
}
type GetAshiatoListResponseObject interface {
    VisitGetAshiatoListResponse(w http.ResponseWriter) error
}

type GetAshiatoList200JSONResponse GetAshiatoListResponse

func (response GetAshiatoList200JSONResponse) VisitGetAshiatoListResponse(w http.ResponseWriter) error {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(200)

    return json.NewEncoder(w).Encode(response)
}

type GetAshiatoList400Response struct {
}

func (response GetAshiatoList400Response) VisitGetAshiatoListResponse(w http.ResponseWriter) error {
    w.WriteHeader(400)
    return nil
}

type GetAshiatoList500Response struct {
}

func (response GetAshiatoList500Response) VisitGetAshiatoListResponse(w http.ResponseWriter) error {
    w.WriteHeader(500)
    return nil
}

嬉しい点

GetAshiatoList の戻り値であるところの GetAshiatoListResponseObject は interface です。GetAshiatoList200JSONResponse GetAshiatoList400Response GetAshiatoList500Response が同時に生成されており、これらの構造体には左記の interface を満たすような関数が実装されています。

つまり、GetAshiatoList が返すことができる値はこれら3つの構造体に制限されることになります。これら3つの構造体のうちのひとつを選んで戻り値とすることで、自動的にレスポンスの形に変換されてクライアント側に返却されていきます。この仕組みは、仕様に書いていない内容を誤ってレスポンスしてしまうことを防いでくれます2。

Go の標準ライブラリを使って HTTP ハンドラを実装する場合、レスポンスする際には http.ResponseWriter に byte の配列 (多くの場合 JSON)を書き込む実装をします。このやり方だと任意の byte 列を書き込めてしまうため、仕様通りのレスポンスを書き込んでいるかどうかを開発者がケアする必要があります (oapi-codegen を用いて strict-server オプションをつけなかったときも同様です) 。StrictServerInterface を実装するやり方をすれば、レスポンス内容を取り違える心配が大きく軽減されて便利です。

補足

middleware (定番の形をしている) を挿す

StrictServerInterface を用いるとき、middleware を構築するために以下のような専用の型が用意されています。Go の HTTP サーバーを実装するときにしばしば目にするような middleware の形とちょっと違っています。

type StrictHTTPMiddlewareFunc func(f StrictHTTPHandlerFunc, operationID string) StrictHTTPHandlerFunc

しばしば目にする定番の middleware といえば、以下の型ではないでしょうか。http.Handler を引数にとって http.Handler を返却する関数の形です。

func typicalMiddleware(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // 何か処理をする
    next.ServeHTTP(w, r)
  })
}

chi3 であったり gorilla/mux4 であったり、HTTP サーバーを作ろうと思ったときに使われる定番のライブラリには middleware が準備されているものが多く、その形式は概ね定番の形が踏襲されています。StrictHTTPMiddlewareFunc に当てはめようと思うとちょっとひと手間が必要になり、不便です。できればそのまま使いたいでしょう。大丈夫です。そのまま再利用できます。

   // chi を使う例
    router := chi.NewRouter()

    // chi が準備してくれている middlewares を設定する
    router.Use(chimiddleware.Logger)
    router.Use(chimiddleware.Recoverer)
    router.Use(chimiddleware.Timeout(25 * time.Second))

    // requestHandler は StrictServerInterface を実装する構造体
    handler := &requestHandler{}
 
    // oapi-codegen に自動生成してもらった Handler インスタンス生成のための関数を呼び出す
    // 第二引数で設定できる専用の型の middleware は設定しない (nil にしておく)
    h := openapi.HandlerFromMux(openapi.NewStrictHandlerWithOptions(handler, nil, openapi.StrictHTTPServerOptions{}), router)

    // ListenAndServe する
    server := &http.Server{ Handler: h }
    server.ListenAndServe()

まとめ

oapi-codegen でサーバーアプリのコード生成をするときのオプションのひとつである strict-server オプションを紹介しました。仕様として記載した内容からサーバーの実装が乖離しづらくなり、たいへん便利です。オススメです。


  1. ゲストブック - Wikipedia
  2. 工夫をすればこれら以外を返すことも可能そうですが、あえて工夫をしない限りは大丈夫
  3. https://github.com/go-chi/chi
  4. https://github.com/gorilla/mux