cue言語を利用してOpenAPIファイルを生成する
3行で
- OpenAPIをJSONなり、YAMLで書いていくのがなんだか辛い
- 問題を緩和するために設定記述言語である cue を使ってみる
- 良し悪しがありそうだぞ
OpenAPIが大きくなってくると辛い問題
OpenAPIを取り扱うとき、製品コードからOpenAPIを生成するパターンだとか、逆にOpenAPIファイルを最初に書いて、それをもとに製品コードのスケルトンを作っていくパターンがあるとは思う。
どちらかというと、後者のパターンを想定したときに、まずOpenAPIファイルありきで考えることになるのだが、OpenAPIファイル自体というか、JSONなりYAMLの記述が少しつらい面があるとは思う。
たとえば、以下のような問題である。
- どこでインデントしてるんだ?
- これ、合ってるのか?
- 読むのが辛い
- JSONの場合コメントが書けないぞ。(というのもあり、JSONで書くことはないかな?)
- 同じことを何度も書いている気がする
などなど。
また、OpenAPIには、ファイルを分割して参照することができる機能が存在するにはするのだが、これに癖を感じてしまう。
たとえば、分割に関して述べた記事は以下のようなものが挙げられる
一番面倒だと感じるポイントとしては、ドキュメントに記載している、Reference Object が利用できる箇所でないと、外部のファイルをロードすることができない点にある。
の記載で、
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を採用したことにより、ほんの少し知名度が上がった。
この記事では、入門は行わないので、以下のすばらしい記事群を読むか、
この記事を作成するにあたって、奮闘したスクラップを読むと良い。
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には対応していない。必要であれば追加していく必要がある。
component/schema パッケージ
Schema 情報を定義するためのもの。たとえば、Userを定義するためのファイルは以下のようになる。
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の認証周りについて記載するために同じのを記載することをやめるために用意しておく。
package path
#oauth2_read: {
OAuth2: ["read"]
}
#oauth2_write: {
OAuth2: ["write"]
}
users.cue
には、/users
と、/users/{id}
の2つのエンドポイントを定義している。
security
フィールドでは、先ほど用意した、#oauth2_read
を利用することで、同じ内容を書くことを避けている。
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オブジェクト全体の制約を与えることができる。
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 形式に変換するといったユースケースが記載されている。
ただし、この機能については、 (5/10追記参照)cue
コマンドからは利用することができず、goのライブラリ経由からでしか利用することができない。
また、現状は、components.schemas
の定義のみにしか対応しておらず、paths
や、その他諸々には対応していない問題がある。
これも試してみる。まずは、Schema定義を行う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
形式での出力が可能なことが判明した。
やり方としては、cue export
の --out
オプションに、openapi
を指定すれば良い。
これにより、今回自作したgoのプログラムと、同じ結果が得られる。なんだ。。
ただし、cue export --help
を読んでもそんなことができるとは書いてないし、公式のマニュアルには、
CUE currently only supports generating OpenAPI through its API.
とあることから、なんだかの事情で公式に利用できるものではないという状況にあると思われる。
この点についても、ちょっと採用しづらいポイントになる。
Discussion