📝

Ginのパラメータバリデーション機能をopenapi.ymlの自動生成コードで使えるようにした

に公開

はじめに

カウンターワークスのバックエンドエンジニア 小関と申します。

昨年、ショップカウンターというサービスの機能の一部をGoに移植するプロジェクトがあり、
工数をなるべく削減するためにopenapi.ymlから自動生成されるコードを利用することができないか検討することになりました。

対象

GoでGinを使っており、パラメータの取得とバリデーションをopenapi.ymlから自動生成されるコードで行いたい方。

前提

コードの自動生成に依存度が高いと実装の自由度が低下してしまうため、次のものだけに絞ることにしました。

  • リクエストボディ
  • クエリパラメータ
  • レスポンス

※レスポンスに関してはデフォルトで構造体が自動生成されていたため、本記事の中では言及しておりません。

やったこと

  • 自動生成ライブラリの選定
  • リクエストボディでGinのバリデーション機能を使えるようにする
    • openapi.ymlにリクエストボディのバリデーションを追加する
    • テンプレートをカスタマイズして、リクエストボディのBind関数を生成する
  • クエリパラメータでGinのバリデーション機能を使えるようにする
    • openapi.ymlにクエリパラメータのバリデーションを追加する
    • テンプレートをカスタマイズして、クエリパラメータのBind関数を生成する
  • openapi-generatorのコマンド
  • mustacheテンプレートの理解

自動生成ライブラリの選定

結論から言うと、openapi-generatorというライブラリを使うことにしました。
他に比較したものとして、

があるが選定理由として、ogenはopenapi.ymlの記述ルールが厳しく、少しでも違うとコードの自動生成ができませんでした。
oapi-codegenは出力が1ファイルにまとまるためサイズが大きくなってしまうのと、リクエストボディとクエリパラメータとレスポンスに関わる部分のみを出力する方法がライブラリ単体としてありませんでした(yqを使ってymlを整形すれば可能)。
openapi-generatorはフロントエンドでも使っており、ライブラリ単体てリクエストボディ、クエリパラメータ、レスポンスに関わる部分のみを出力することができたため採用しました。

リクエストボディでGinのバリデーション機能を使えるようにする

Ginではgo-playground/validatorを使っています。
構造体のタグでbinding:"required"のように記述することで、GinのBind関数でパラメータ取得時にバリデーションを行うことができます。
下記の例では、userとpasswordというパラメータが存在し、入力値の存在(required)をチェックしています。

type Login struct {
    User     string `json:"user" binding:"required"`
    Password string `json:"password" binding:"required"`
}

if err := c.ShouldBindJSON(&json); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

これらの構造体とBindを行う関数を自動生成できるようにします。

openapi.ymlにリクエストボディのバリデーションを追加する

x-go-custom-tagの記述を追加して、binding:"required"を自動生成できるようにします。

requestBody:
  content:
    application/json:
      schema:
        type: object
        description: ファイルの情報
        required:
          - name
        properties:
          name:
            type: string
            description: ファイル名
            example: 'photo.png'
            x-go-custom-tag: binding:"required"

これで次のような構造体が出力できます。

type HogeRequest struct {
	// ファイルの名前
	Name string `json:"name" binding:"required"`
}

テンプレートをカスタマイズして、リクエストボディのBind関数を作成する

リクエストボディのテンプレートはmodel.mustacheに記載されており、Bind関数はデフォルトで生成されるようになっていないためカスタマイズする必要があります。

まず、model_bind_method.mustacheファイルを作成して下記を記載します。

// カスタムで追加したメソッド
func Bind{{classname}}(ctx *gin.Context) (
	*{{classname}},
	error,
) {
	var request {{classname}}
	if err := ctx.ShouldBindJSON(&request); err != nil {
		return nil, err
	}

	return &request, nil
}

そして、model_bind_method.mustacheファイルを読み込むための記述をmodel.mustacheの{{^isEnum}}...{{/isEnum}}の間に追加します。

{{>model_bind_method}}

後述しますが、openapi-generatorのコマンドでテンプレートを指定して自動生成を実行することで、次のようなコードが生成されます。

func BindHogeRequest(ctx *gin.Context) (
	*HogeRequest,
	error,
) {
	var request HogeRequest
	if err := ctx.ShouldBindJSON(&request); err != nil {
		return nil, err
	}

	return &request, nil
}

クエリパラメータでGinのバリデーション機能を使えるようにする

リクエストボディと同様に構造体とBind関数を自動生成されるようにします。

openapi.yml上にクエリパラメータのバリデーションを追加する

リクエストボディのとき同様に、x-go-custom-tagに設定を追加します。

parameters:
  - name: name
    in: query
    description: 名前
    required: true
    x-go-custom-tag: binding:"required"
    schema:
      type: string

クエリパラメータは構造体とBind関数が自動生成されるようになっていないため、テンプレートをカスタマイズします。

テンプレートをカスタマイズして、構造体とBind関数を生成する

クエリパラメータの自動生成はapi.mustacheに記載されており、構造体もBind関数も自動生成されるようになっていなかったため、テンプレートをカスタマイズします。

テンプレートの細かい説明は後述しますが、次の記述をapi.mustacheに記載することで構造体とBind関数を自動生成できます。

package {{packageName}}

{{#operations}}
import (
	"github.com/gin-gonic/gin"
)
{{#operation}}
{{#queryParams.0}}

type {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}QueryParameter struct {
{{/queryParams.0}}
{{#queryParams}}
	{{vendorExtensions.x-export-param-name}} {{^required}}{{^isNullable}}{{^isArray}}{{^isFreeFormObject}}*{{/isFreeFormObject}}{{/isArray}}{{/isNullable}}{{/required}}{{{dataType}}} `form:"{{{baseName}}}{{#defaultValue}},default={{{defaultValue}}}{{/defaultValue}}"{{#withXml}} xml:"{{{baseName}}}{{#isXmlAttribute}},attr{{/isXmlAttribute}}"{{/withXml}}{{#vendorExtensions.x-go-custom-tag}} {{{.}}}{{/vendorExtensions.x-go-custom-tag}}`
{{/queryParams}}
{{#queryParams.0}}
}
{{/queryParams.0}}
{{#queryParams.0}}

func Bind{{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}QueryParameter(ctx *gin.Context) (
	*{{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}QueryParameter,
	error,
) {
	var params {{#structPrefix}}{{&classname}}{{/structPrefix}}{{^structPrefix}}Api{{/structPrefix}}{{operationId}}QueryParameter
	if err := ctx.ShouldBindQuery(&params); err != nil {
		return nil, err
	}

	return &params, nil
}
{{/queryParams.0}}
{{#queryParams}}

こちらも後述しますが、openapi-generatorのコマンドでテンプレートを指定して自動生成を実行することで次のようなコードが生成されます。
packageやimport箇所は省略しています。

type ApiHogeQueryParameter struct {
	Name *string `form:"name" binding:"required"`
}

func BindApiHogeQueryParameter(ctx *gin.Context) (
	*ApiHogeQueryParameter,
	error,
) {
	var params ApiHogeQueryParameter
	if err := ctx.ShouldBindQuery(&params); err != nil {
		return nil, err
	}

	return &params, nil
}

openapi-generatorコマンド

テンプレートをカスタマイズしたため、コマンドでテンプレートを指定をしたり、
テンプレート上で使えるパラメータを確認する必要があるため、そのオプションやデバッグ方法について記載します。

openapi-generatorで使えるオプションや機能についてはgithubのopenapi-generatorのドキュメントを確認しながらコマンドを実行して出力を確認しました。

自動生成コマンド

-iで定義ファイル、-gでgeneratorとしてgoを、-oで出力先、-pでパッケージ名、
--global-propertyで出力対象、--template-dirでカスタマイズしたテンプレートのディレクトリを指定しています。

docker run --rm -t -v {{.ROOT_DIR}}:/local openapitools/openapi-generator-cli:{{.VERSION}} \
  generate \
  -i /local/openapi.yml \
  -g go \
  -o /local/output \
  -p packageName=package_name \
  --global-property models,supportingFiles,apis,apiTests=false,apiDocs=false \
  --template-dir /local/openapi-generator-template

デバッグコマンド

openapi-generatorにはデバッグ機能があり、テンプレートで使用可能なパラメータを調べることができます。--global-propertyでdebugModelsやdebugOperationsでデバッグログを出力することができますが、出力が膨大なためdebugModelsかdebugOperationsを単体で指定して個別に確認しました。

docker run --rm -t -v {{.ROOT_DIR}}:/local openapitools/openapi-generator-cli:{{.VERSION}} \
  generate \
  -i /local/openapi.yml \
  -g go \
  -p packageName=package_name \
  --global-property debugModels,debugOperations

一部出力

"description" : "Hoge",
"dataType" : "string",
"datatypeWithEnum" : "string",

このようにデバック用のオプションで出力を確認して、api.mustacheなどのテンプレートでパラメータを指定して自動生成されるコードを調整することができます。

mustacheテンプレートの理解

openapi-generatorではmustacheというテンプレートが使用されており、
mustacheを理解することで、コードの自動生成を拡張できるようになるため、
プロジェクトに合わせて独自の機能を追加したい場合は理解が必須となります。

基本形

  • ハッシュデータ
{
  "name": "CW",
  "company": "<b>Counterworks</b>"
}
  • テンプレート
* {{name}}
* {{age}}
* {{company}}
* {{{company}}}
  • 出力
* CW
*
* &lt;b&gt;Counterworks&lt;/b&gt;
* <b>Counterworks</b>

ドット付き名前

client.nameの形で値を取得できる。

  • ハッシュデータ
{
  "client": {
    "name": "CW & SP",
    "age": 50
  },
  "company": {
    "name": "<b>Counterworks</b>"
  }
}
  • テンプレート
* {{client.name}}
* {{age}}
* {{{company.name}}}
  • 出力
* CW &amp; SP
*
* <b>Counterworks</b>

セクション

{{#hoge}}がtrueの場合にセクション内のものを表示。

  • ハッシュデータ
{
  "is_shown": false
}
  • テンプレート
Shown.
{{#is_shown}}
  Never shown.
{{/is_shown}}
  • 出力
Shown.

リスト1

通常のリストの場合。

  • ハッシュデータ
{
  "list": ["resque", "hub", "rip"]
}
  • テンプレート
{{#list}}
{{.}}
{{/list}}
  • 出力
resque
hub
rip

リスト2

リストがハッシュの場合。

  • ハッシュデータ
{
  "list": [
    { "name": "resque" },
    { "name": "hub" },
    { "name": "rip" }
  ]
}
  • テンプレート
{{#list}}
{{name}}
{{/list}}
  • 出力
resque
hub
rip

逆セクション

{{#hoge}の反対で{{^hoge}}はfalse、もしくは空の場合にセクション内のものが表示。

  • ハッシュデータ
{
  "list": []
}
  • テンプレート
{{#list}}
  Never shown.
{{/list}}
{{^list}}
  Shown.
{{/list}}
  • 出力
  Shown.

コメント

{{! hoge }}がテンプレートのコメントになる。

  • テンプレート
<h1>Today{{! ignore me }}.</h1>
  • 出力
<h1>Today.</h1>

パーシャル

テンプレートファイルを分ける。

  • テンプレート(base.mustache)
<h2>Names</h2>
{{> user}}
  • テンプレート(user.mustache)
user template
  • 出力
<h2>Names</h2>
user template

おわりに

開始当初、swagを使用してコード上のコメントからopenapi.ymlを生成する方式をとっていたのですが、APIの設計と実装が分離しにくかったため、openapi.ymlで先にAPIの設計をしてから実装する方針変更がありました。
その際、コードの自動生成を利用して工数を下げたいという要望があり、今回の対応をすることになりました。
また、Goに移植する際の設計でモジュラーモノリスで実装することになっていたため、openapi.ymlから自動生成されるコードに依存しすぎると自由度が下がるため、自動生成されるコードを絞って一つのモジュールとして扱うという背景がありました。

今回の内容は現在記事になっていなかったり、断片的に情報が存在するだけで、正確な情報を得るためには公式のドキュメントやgithubからコードを確認したりする必要があったため、まとまった情報があれば便利だと思いまとめることにしました。

記事にしたこと以外にも自動生成のコードでlintエラーが発生したり、既存の自動生成ファイルがopenapi-generator再実行時に削除されないため自動生成コマンド実行時に古いファイルを削除したりと細かい対応もありましたが、根本的な課題ではなかったため省略させていただきました。

今後はgo-playgloud/validatorの豊富なバリデーションをより有効に使えるようにし、go-playgloud/validatorでは補いきれない部分をカスタムバリデーションを作成して補えるようにしていこうと考えています。

参考

COUNTERWORKS テックブログ

Discussion