📝

cue言語を利用してOpenAPIファイルを生成する

2022/05/09に公開

3行で

  • OpenAPIをJSONなり、YAMLで書いていくのがなんだか辛い
  • 問題を緩和するために設定記述言語である cue を使ってみる
  • 良し悪しがありそうだぞ

OpenAPIが大きくなってくると辛い問題

OpenAPIを取り扱うとき、製品コードからOpenAPIを生成するパターンだとか、逆にOpenAPIファイルを最初に書いて、それをもとに製品コードのスケルトンを作っていくパターンがあるとは思う。
どちらかというと、後者のパターンを想定したときに、まずOpenAPIファイルありきで考えることになるのだが、OpenAPIファイル自体というか、JSONなりYAMLの記述が少しつらい面があるとは思う。

たとえば、以下のような問題である。

  • どこでインデントしてるんだ?
  • これ、合ってるのか?
  • 読むのが辛い
  • JSONの場合コメントが書けないぞ。(というのもあり、JSONで書くことはないかな?)
  • 同じことを何度も書いている気がする

などなど。

また、OpenAPIには、ファイルを分割して参照することができる機能が存在するにはするのだが、これに癖を感じてしまう。
たとえば、分割に関して述べた記事は以下のようなものが挙げられる

https://garafu.blogspot.com/2020/06/multi-file-openapi.html
https://qiita.com/KUMAN/items/543b147651dc32065191

一番面倒だと感じるポイントとしては、ドキュメントに記載している、Reference Object が利用できる箇所でないと、外部のファイルをロードすることができない点にある。

https://swagger.io/docs/specification/using-ref/

の記載で、

openapi: 3.0.0
# Incorrect!
info:
  $ref: info.yaml
paths:
  $ref: paths.yaml

However, you can $ref individual paths, like so:

paths:
  /users:
    $ref: '../resources/users.yaml'
  /users/{userId}:
    $ref: '../resources/users-by-id.yaml'

という記載があることから察するに、OpenAPIのファイル分割の際に誤りやすい要素であると考えられる。

たとえば、paths について、/users, /users/{userId} の内容は同じファイル内で取り扱いたいといった状況に対応することはできず、path の内容ごとに1つづつYAMLファイルを作ることになる。$ref 地獄! なOpenAPIファイルに苦しめられる感が拭えない。

設定記述言語 cue を検討する

cue は go で書かれた設定記述言語と呼ばれるもので、名前の通り設定ファイルをうまいところ作るために作られた言語である。類似なツールとしては、Kustomize や、Jsonnet などが挙げられる。

最近 CI/CD の設定フレームワークとして、Daggerがcueを採用したことにより、ほんの少し知名度が上がった。

この記事では、入門は行わないので、以下のすばらしい記事群を読むか、

https://zenn.dev/riita10069/articles/plactice_cuelang

https://cuetorials.com/

この記事を作成するにあたって、奮闘したスクラップを読むと良い。

https://zenn.dev/kawahara/scraps/5b70e382c2a8f6

cue によるOpenAPI記述の構成案

ディレクトリによって、うまくファイルをまとめて行きたいので、モジュールの仕組みを利用する。
このモジュールの仕組みは、goのモジュールの仕組みをベースとしており、goを普段から触っている人であればおなじみの構成かもしれない。
わたしの用に普段goをメインで触ってない身としては、慣れないなーという感想を覚えた。

モジュールを作る

まずは、空のディレクトリで以下のコマンドを実行し、モジュールを作る。
モジュール名は、ドメイン名/パス という形式になる。goだと、ドメイン名でなくても良いが、cueの場合は、正しいドメイン形式でないと受け入れてくれない。

cue mod init "bucyou.net/openapi"

これにより、以下のようなファイル構成になる。

.
└── cue.mod
    ├── module.cue
    ├── pkg
    └── usr

3 directories, 1 file

module.cue には、module: "bucyou.net/openapi" だけが記載されており、cue.mod が配置してあるディレクトリが、何のモジュールなのかを示している。

構成案

最終的に、以下のような構成としてみた。

.
├── component
│   └── schema
│       └── user.cue
├── cue.mod
│   ├── module.cue
│   ├── pkg
│   └── usr
├── definition
│   └── openapi.cue
├── path
│   ├── security.cue
│   └── users.cue
└── root.cue

7 directories, 6 files
ファイル 役割
component/schema/*.cue Schema を定義するファイルを置く
cue.mod/ モジュールを定義するもの。今回はpkg, user などは利用しない
definition/openapi.cue OpenAPI Object の定義を行うためのもの
path/*.cue パス情報
root.cue OpenAPI Object を作るルートファイル

definition パッケージ

OpenAPI のフォーマットを定義するためのもの。OpenAPI 3.0.3の仕様をもとに作成したファイルを配置する。これを利用することで、これから作成する情報に、OpenAPIの形式での制約を与えることができる。
なにか、仕様などを勘違いして書いたら、cue が問題点を指摘してくれる。

以下のファイルが内容だが、#schema については、完全に対応できていないほか、Specification Extensionsには対応していない。必要であれば追加していく必要がある。

https://gist.github.com/kawahara/8598215e4d1cce23e12b313d6824d362

component/schema パッケージ

Schema 情報を定義するためのもの。たとえば、Userを定義するためのファイルは以下のようになる。

component/schema/user.cue
package schema

import "bucyou.net/openapi/definition"

User: definition.#schema & {
	type: "object"
	properties: {
		id: {
			type: "string"
			description: "uuidv4 format"
			readOnly: true
		},
		name: {
			type: "string"
			description: "user name"
		}
		email: {
			type: "string"
			description: "user email"
		}
	}
	required: [
		"id",
		"name",
		"email"
	]
}

先ほど用意した、definitionパッケージの、openapi.cue の中で用意している、#schema を利用して、制約を与える。
ここでは、User のみを定義しているが、同じファイルに、Article フィールドを増やしても問題ないし、component/schema ディレクトリに、article.cue というファイルを作成し、Article フィールドを定義してもよい。

ここは柔軟に記載できるがゆえの取り決めが必要な部分かと思われる。

path パッケージ

pathパッケージには、共通で利用するものを用意しておく。以下は、APIの認証周りについて記載するために同じのを記載することをやめるために用意しておく。

path/security.cue
package path

#oauth2_read: {
	OAuth2: ["read"]
}

#oauth2_write: {
	OAuth2: ["write"]
}

users.cue には、/users と、/users/{id} の2つのエンドポイントを定義している。
security フィールドでは、先ほど用意した、#oauth2_read を利用することで、同じ内容を書くことを避けている。

path/users.cue
package path

import "bucyou.net/openapi/definition"

"/users": definition.#path & {
	get: {
		tags: ["Users"]
		summary: "List users"
		security: [#oauth2_read]
		responses: {
			"200": {
				description: "Successful operation"
				content: {
					"application/json": {
						schema: {
							type: "array"
							items: {
								$ref: "#/components/schemas/User"
							}
						}
					}
				}
			}
		}
	}
}

"/users/{id}": definition.#path & {
	get: {
		tags: ["Users"]
		summary: "Get a user"
		security: [#oauth2_read]
		parameters: [
			{
				name:        "id"
				in:          "path"
				description: "User Id"
				required:    true
				schema: {
					type: "string"
				}
			},
		]
		responses: {
			"200": {
				description: "Successful operation"
				content: {
					"application/json": {
						schema: {
							$ref: "#/components/schemas/User"
						}
					}
				}
			}
		}
	}
}

これも、Schema の定義の場合と同じく、複数のエンドポイントを1つのファイルにまとめることもできるし、分けて記載することもできるようになっている。

root パッケージ

component/schema パッケージと、path パッケージをまとめるルートパッケージ。
definition.#openapi により、OpenAPIオブジェクト全体の制約を与えることができる。

root.cue
package root

import (
	"bucyou.net/openapi/path"
	"bucyou.net/openapi/definition"
	"bucyou.net/openapi/component/schema"
)

definition.#openapi & {
        // openapi フィールドは、definition.#openapi で 3.0.3 で確定させているので
	// ここに記載する必要はない
	info: {
		title:   "The sample API"
		version: "0.0.1"
	}
	servers: [
		{
			url:         "http://localhost:3000/v1"
			description: "local server"
		},
	]
	// path パッケージの中で用意した構造を展開する
	paths: path
	components: {
		// comopnent/schema パッケージの中で用意した構造を展開する
		schemas: schema
		securitySchemes: {
			OAuth2: {
				type: "oauth2"
				flows: {
					authorizationCode: {
						authorizationUrl: "https://example.com/oauth/authorize"
						tokenUrl:         "https://example.com/oauth/token"
						scopes: {
							read:  "Grants read access"
							write: "Grants write access"
							admin: "Grants all access"
						}
					}
				}
			}
		}
	}
}

出力

cue.mod の配置してあるディレクトリで、以下のコマンドを実行することで、YAML形式のデータが出力される。

cue export root.cue --out yaml
openapi: 3.0.3
info:
  title: The sample API
  version: 0.0.1
servers:
  - url: http://localhost:3000/v1
    description: local server
paths:
  /users:
    get:
      tags:
        - Users
      summary: List users
      security:
        - OAuth2:
            - read
      responses:
        "200":
          description: Successful operation
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
  /users/{id}:
    get:
      tags:
        - Users
      summary: Get a user
      security:
        - OAuth2:
            - read
      parameters:
        - name: id
          in: path
          description: User Id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
          description: uuidv4 format
          readOnly: true
        name:
          type: string
          description: user name
        email:
          type: string
          description: user email
      required:
        - id
        - name
        - email
  securitySchemes:
    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://example.com/oauth/authorize
          tokenUrl: https://example.com/oauth/token
          scopes:
            read: Grants read access
            write: Grants write access
            admin: Grants all access

まとめと今後の課題

この形式の良し悪しについて考えてみる

良いところ

  • 強力な型制約。cueからopenapiに変換する時点で間違えに気づけるし、命名規則といったチーム内独特なより厳しい制約を OpenAPI に与えていくことも可能になる
  • YAML や JSON が書きにくい問題の解消
  • 繰り返し書かなくてはならない場面に対応しやすい
  • ファイル構成などがわりと自由が効き、巨大化する OpenAPI ファイルに対応しやすい。(これは自由度がありすぎることによるデメリットにもなりうる)

悪いところ

  • cuelang 自体の学習コスト
  • OpenAPI の$ref 利用時などにIDEの入力補完が効いていたものが、このやりかたにより使えなくなる
  • 作り方間違えると難解なものができそう
  • go に慣れていないと、モジュールの仕組みが微妙にわかりにくい
  • Dagger で採用されたこともあり、知名度は少しあがったものの、みんな使っているという感じではない。
  • APIを作るための、OpenAPIファイルを作る cue というなんだかメタな感じ。cue ファイルを go のライブラリ使って直接読んで API 作る仕組みとか作ればいいんではないか? 論もあるが、OpenAPI ファミリーのツール郡や、ある程度整った共通仕様に乗っかりたいと思う悩み

というわけで、これをやってみたことで cuelang おもしろ! とはなったものの、実際のプロジェクトで採用するかどうかは、良し悪しについて、よく話し合う必要はあるとは思う。

(備考) cue の OpenAPI encoder について

ところで、cueのドキュメントを眺めていると、cueの定義からOpenAPI 形式に変換するといったユースケースが記載されている。

ただし、この機能については、cue コマンドからは利用することができず、goのライブラリ経由からでしか利用することができない。 (5/10追記参照)
また、現状は、components.schemas の定義のみにしか対応しておらず、paths や、その他諸々には対応していない問題がある。

これも試してみる。まずは、Schema定義を行うcueファイルを作成する。

openapi.cue
// Title
$version: "1.0"

// Info describes...
#Info: {
    // Name of the adapter.
    name: string

    // Templates.
    templates?: [...string]

    // Max is the limit.
    max?: uint & <100
}

コメント行は大事なもので、それぞれのスキーマにおける、description として機能するものになる。
また、フィールドが必須でない場合を示す、? や、範囲指定 なども、Schemaの出力のために利用される。

次に、以下のようなgoコードを作成する。ファイルをcueのライブラリで読み取り、openapi用のエンコーダーを使い変換し、JSON形式で出力するといった流れになっている。

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"

	"cuelang.org/go/cue"
	"cuelang.org/go/cue/load"
	"cuelang.org/go/encoding/openapi"
)

func main() {
	err := openAPISchemas(os.Args[1:])
	if err != nil {
		fmt.Println(err)
	}
}

func openAPISchemas(entrypoint []string) error {
	// get cue instance
	bis := load.Instances(entrypoint, nil)
	insts := cue.Build(bis)
	for _, inst := range insts {
		b, err := openapi.Gen(inst, nil)
		if err != nil {
			return err
		}

		var out bytes.Buffer
		err = json.Indent(&out, b, "", "   ")
		if err != nil {
			return err
		}

		fmt.Println(string(out.Bytes()))
	}

	return nil
}

コードを置いて、実行してみる。

go mod init bucyou.net/cue_to_openapi
go mod tidy
go run main.go openapi.cue

これにより、以下のJSONデータが作成される。

{
   "openapi": "3.0.0",
   "info": {
      "title": "Title",
      "version": "1.0"
   },
   "paths": {},
   "components": {
      "schemas": {
         "Info": {
            "description": "Info describes...",
            "type": "object",
            "required": [
               "name"
            ],
            "properties": {
               "name": {
                  "description": "Name of the adapter.",
                  "type": "string"
               },
               "templates": {
                  "description": "Templates.",
                  "type": "array",
                  "items": {
                     "type": "string"
                  }
               },
               "max": {
                  "description": "Max is the limit.",
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 100,
                  "exclusiveMaximum": true
               }
            }
         }
      }
   }
}

なかなか、便利そうではあるが、Schemaだけしか対応していないというのはやはりちょっと辛い。
とはいえ、ライブラリは提供されており、いろいろやることはできそうなので、これをベースにして、Paths にも対応させるプログラムを作ったり、cueのAttributeの仕組みを利用して、認証などの情報を与えたりなど、いろいろできそうであるということを感じた。

go をもうちょっとやらんとなー。などと思った2022年GWであった。


(備考の追記): cue コマンドでOpenAPIへの出力ができた!

cue のソースコードを眺めていて気づいたこととして、cue コマンドで、openapi 形式での出力が可能なことが判明した。

https://github.com/cue-lang/cue

やり方としては、cue export--out オプションに、openapi を指定すれば良い。
これにより、今回自作したgoのプログラムと、同じ結果が得られる。なんだ。。

ただし、cue export --help を読んでもそんなことができるとは書いてないし、公式のマニュアルには、

CUE currently only supports generating OpenAPI through its API.

https://cuelang.org/docs/integrations/openapi/

とあることから、なんだかの事情で公式に利用できるものではないという状況にあると思われる。
この点についても、ちょっと採用しづらいポイントになる。

Discussion