Closed22

cue言語でOpenAPIのYAMLファイルを生成したい

ooharabucyouooharabucyou

OpenAPI の YAML ファイルを作成するのは辛い。これは OpenAPI の問題というよりは、JSON、YAMLの問題と言ったほうがいいかも知れない。

辛いポイントはだいたい @suin さんが、言っている通り。

https://twitter.com/suin/status/1515139377797558274

他の問題として、ファイル分割の問題というのがある。
OpenAPI の YAML のファイル分割については、以下などで論じられている。

https://qiita.com/KUMAN/items/543b147651dc32065191

ただ、$ref 自体の仕様も、なんだか微妙で、https://swagger.io/docs/specification/using-ref/ によると、

openapi: 3.0.0
# Incorrect!
info:
  $ref: info.yaml
paths:
  $ref: paths.yaml
paths:
  /users:
    $ref: '../resources/users.yaml'
  /users/{userId}:
    $ref: '../resources/users-by-id.yaml'

という注意書きがあったり、Aのファイルで定義した部分を、Bでも使いまわしたくてー、みたいなケースだったりとかに耐えられなかったりと、上げていけばキリがなさそう。

ということで、設定記述言語 cue というものがあることを知り、利用の検討をしてみようと思う。
最終的に、OpenAPIの記述に cue を使うかはまだ未決定である。

https://zenn.dev/riita10069/articles/plactice_cuelang
https://speakerdeck.com/ytaka23/kubernetes-meetup-tokyo-29th

ooharabucyouooharabucyou

はじめの一歩

main.cue
hello: {
	world: "hoge"
}

とりあえず、こんなものを書いてみる。
cue export で --out yaml を指定すると、yaml が生成される。

cue export main.cue --out yaml
hello:
  world: hoge
ooharabucyouooharabucyou

コメントは、C言語のように、// で記載できる。

main.cue
hello: {
	// comment
	world: "hoge"
}

JSONや、YAMLと違って良いポイントとしては、一度記載したオブジェクトに、値をマージすることができる点。

main.cue
hello: {
	world: "hoge"
}

hello: {
	hoge: "fuga"
}
cue export main.cue --out yaml
hello:
  world: hoge
  hoge: fuga
ooharabucyouooharabucyou

他のオブジェクトの値の参照ができる

main.cue
foo: {
	bar: "hoge"
}


hello: {
	world: foo.bar
}
foo:
  bar: hoge
hello:
  world: hoge

alias として、参照を変数として定義することもできる。
順序は関係ないので、let A = a を b: {} の下に移動しても動作する。

main.cue
let A = a
a: {
	d: 3
}
b: {
	a: {
		c: A.d
	}
}
a:
  d: 3
b:
  a:
    c: 3
ooharabucyouooharabucyou

キーの最初に、# もしくは _# で始めることで、定義を行うことができる。

型としては以下を利用することができる。

  • null: null型。JSONでいう null
  • bool: JSONでいう boolean
  • string: 文字列。cue言語上ではダブルクオート " に囲われる。もしくは、YAML のように、""" という3つのダブルクオートと使うことで、複数行の文字列を表現することができる。後で確認する。
  • bytes: バイナリ。cue言語上では シングルクオート ' に囲われて、'\x03abc' のように16進数で記載できる。 JSONに変換するときは base64encode される
  • number: 数値。さらに、int もしくは、float に細分される。
  • struct: 構造体。
  • list: リスト。JSONでいう array

以下のように、go_to_park は、#Task という定義に従って中身を記載する必要が強制される。

#Task: {
	name: string
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
	rank: 1
}
go_to_park:
  name: 公園に行く
  rank: 1

例えば、rank に数値以外を入れようとするとエラーになる。

#Task: {
	name: string
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
	rank: "error"
}
go_to_park.rank: conflicting values int and "error" (mismatched types int and string):
    ./main.cue:3:8
    ./main.cue:8:8

定義されたものが用意されていない場合でもエラーになる

#Task: {
	name: string
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
}
go_to_park.rank: incomplete value int:
    ./main.cue:3:8
ooharabucyouooharabucyou

以下のコードのように、最初の段階では定義されたものを用意していなくても、最終的に用意できれば問題がない。

#Task: {
	name: string
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
}

go_to_park: {
	rank: 3
}
go_to_park:
  name: 公園に行く
  rank: 3

ただ、最終的に確定した値を、更に上書きすることはできない。

#Task: {
	name: string
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
}

go_to_park: {
	rank: 3
}

go_to_park: {
	rank: 4
}
go_to_park.rank: conflicting values 4 and 3:
    ./main.cue:11:8
    ./main.cue:15:8
ooharabucyouooharabucyou

フィールド名に ? を付与することで、あってもなくても問題がない値になる。

#Task: {
	name: string
	rank?: int
}

go_to_park: #Task & {
	name: "公園に行く"
}
go_to_park:
  name: 公園に行く
ooharabucyouooharabucyou

定義で | を利用することで、いずれか1つのどれか、ということを制約することができる。これは、TypeScript における、UnionType みたいな感じ。

問題がないケース

#Task: {
	name: "公園に行く" | "買い物に行く"
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
	rank: 1
}

go_to_shopping: #Task & {
	name: "買い物に行く"
	rank: 2
}
go_to_park:
  name: 公園に行く
  rank: 1
go_to_shopping:
  name: 買い物に行く
  rank: 2

エラーとなるケース

#Task: {
	name: "公園に行く" | "買い物に行く"
	rank: int
}

go_to_park: #Task & {
	name: "公園に行く"
	rank: 1
}

go_to_shopping: #Task & {
	name: "買い物に行く"
	rank: 2
}

go_to_mountain: #Task & {
	name: "山に行く"
	rank: 3
}
go_to_mountain.name: 2 errors in empty disjunction:
go_to_mountain.name: conflicting values "公園に行く" and "山に行く":
    ./main.cue:2:8
    ./main.cue:16:17
    ./main.cue:17:8
go_to_mountain.name: conflicting values "買い物に行く" and "山に行く":
    ./main.cue:2:28
    ./main.cue:16:17
    ./main.cue:17:8
ooharabucyouooharabucyou

定義のとき * を値の前につけることで、最終的にどの値にも決定されなかったときのデフォルト値を設定することができる。

#Task: {
	name: string
	rank: int | *1
}

go_to_park: #Task & {
	name: "公園に行く"
}
go_to_park:
  name: 公園に行く
  rank: 1
ooharabucyouooharabucyou

数値・文字列・バイナリと null には、範囲の制約を与えることができる。

#Task: {
	name: string
         // int かつ、1以上、100以下、デフォルトは1
	rank: int & >=1 & <=100 | *1
}

go_to_park: #Task & {
	name: "公園に行く"
	rank: 101
}
go_to_park.rank: 2 errors in empty disjunction:
go_to_park.rank: conflicting values 1 and 101:
    ./main.cue:3:29
    ./main.cue:6:13
    ./main.cue:8:8
go_to_park.rank: invalid value 101 (out of bound <=100):
    ./main.cue:3:20
    ./main.cue:8:8
ooharabucyouooharabucyou

特によく利用する整数の範囲については、あらかじめ定義されたものが存在する。

  • uint: 0以上の整数
  • uint8: 0以上255以下の整数。(符号なし8bytes整数)
  • int8: -128以上127以下の整数。(符号あり8bytes整数)
  • uint16: 符号なし16bytes整数
  • int16: 符号あり16bytes整数
  • rune: go言語ではおなじみ? なのか。Unicode のコードポイントの範囲で 0〜0x10FFFFの範囲。
  • uint32: 符号なし32bytes整数
  • int32: 符号あり32bytes整数
  • uint64
  • int64
  • int128
  • uint128

符号なし8bytes整数に抑えつつ、1以上の制約を rank に対して与えたいときは以下のように記載できる。

#Task: {
	name: string
	rank: uint8 & >=1 | *1
}

go_to_park: #Task & {
	name: "公園に行く"
	rank: 256
}
go_to_park.rank: 2 errors in empty disjunction:
go_to_park.rank: conflicting values 1 and 256:
    ./main.cue:3:23
    ./main.cue:6:13
    ./main.cue:8:8
go_to_park.rank: invalid value 256 (out of bound <=255):
    ./main.cue:8:8
ooharabucyouooharabucyou

template 機構というものがあって、予め用意された構造を使い回すことができる。
なかなか強力そうな機能。

main.cue
task: [Name=string]: {
  name: Name
  rank: uint8 & >=1 | *1
  description: string | *""
}

task: 公園に行く: rank: 2
task: 公園に行く: description: "長岡京公園"

task: 買い物に行く: rank: 3
task:
  公園に行く:
    name: 公園に行く
    rank: 2
    description: 長岡京公園
  買い物に行く:
    name: 買い物に行く
    rank: 3
    description: ""
ooharabucyouooharabucyou

では、ファイルを分けたいときはどうするか。yaml 自体にはファイルを外部から読み込む機能はない。
OpenAPI には、 $ref による参照を行う機能は存在するがどうもしっくりこないことは最初で説明したとおりである。

cue は複数ファイルがあっても、最終的に何かに収束すれば良いので、以下のように表現できる。

a.cue
a: {
  message: "This is message"
}
b.cue
a: {
  hello: "world"
}
 cue export a.cue b.cue --out yaml
a:
  message: This is message
  hello: world

ただし、ディレクトリの中にある全てのファイルを読み込む。といったケースには package を使う必要がある。(次の更新で確認)
また、ディレクトリの中に、さらに別のディレクトリを作って階層を管理するといったケースになると、モジュールの仕組みを使う必要がある。これが cuelang のなかなか面倒だと感じた部分。(慣れてないだけかもだけど、なんだか不自然と感じた。)

ooharabucyouooharabucyou

ディレクトリ内のすべてのcueファイルを読み込み評価するには、package を使う必要がある。

以下の2つのファイルを同じディレクトリに入れる。

a.cue
package main

a: {
}
b.cue
package main

b: {
}

そして、カレントディレクトリを a.cue, b.cue が配置してある場所に移動して、export してみる。

 cue export .  --out yaml
a: {}
b: {}

無事に、a.cue, b.cue を評価して、変換した結果が出力される。

なお、同じディレクトリに

c.cue
package main2

c: {
}

を配置すると、以下のようなエラーにより評価をしてくれない。

found packages "main" (a.cue) and main2 (c.cue) in "/foo/bar"
ooharabucyouooharabucyou

個人的にうーん微妙とおもったパッケージの import について。
import は、自分のパッケージ以外のなにか (構造だったり、定義だったり)を読み取って利用するための機構である。

あらかじめ、いくつかの機能については、組み込み機能として備えられている。 例えば、とある値をIPv4アドレスに限定したい場合は、以下のように記すことができる。

a.cue
import "net"

ipv4: net.IPv4
ipv4: "256.1.1.1"
ipv4: invalid value "256.1.1.1" (does not satisfy net.IPv4):
    ./a.cue:3:7
    ./a.cue:4:7

このように、IPv4 のアドレスとしてありえない場合を弾くことができる。

ほかにも、定数として円周率を出力したり

pi.cue
import "math"

pi: math.Pi
pi: 3.14159265358979323846264338327950288419716939937510582097494459

リストの合計値を出したりと、いろいろ機能はある。

sum.cue
import "list"

sum: list.Sum([1, 2, 3, 4, 5])
sum: 15

ここまでは便利そう(?) だが、問題は独自のファイルを読み込みたいパターンであった。

ooharabucyouooharabucyou

まず、import については、相対パスは利用することができない。これは、他の言語をやっていると結構戸惑うポイント。
golang などをやっている人からすると当たり前なのかもしれないが、cue.mod なるディレクトリを定義して、今のディレクトリがどのパッケージの階層なのかを示す必要がある。

モジュールの雛形を作るには、以下のコマンドを利用する。

cue mod init [モジュール名]

モジュール名には、 example.com/foge のように、ドメイン + パスのような形式を指定する。仮にドメインの形式として合っていないもものを指定しようとすると、このコマンドの実行が中断される。
正しく実行されると、cue.mod というディレクトリが作成される。

こんな感じになって、cue.mod/module.cue には、module: "example.com/foo" という情報が記されている。
階層はこんなかんじ。

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

3 directories, 1 file

これで、cue.mod のあるディレクトリのパスが、指定したモジュール名 example.com/foo として扱われる。
試しに、bar/sample.cue というファイルを作って、その後、cue.mod のあるディレクトリに root.cue というファイルを置き、読み込みを試してみる。

bar/sample.cue
package bar

bar: {
  hello: "world"
}
root.cue
package root

#  module名/パッケージ名(パス)
import "example.com/foo/bar"

// bar パッケージから、bar 構造体を呼び出しておく
bar.bar
# root.cue と cue.mod のあるディレクトリで以下を実行
cue export root.cue --out yaml
hello: world

まず、相対パス的な import ができないというところがうーんと思ったところ。
この辺の挙動は、Cuetorials というサイトを参照してようやく理解できた。。

cue.mod/pkg の中には、cue.mod/module.cue で定義したモジュール名とは別の参照モジュール・パッケージを配置することができて、cue.mod/pkg/bucyou.net/something/something.cue の中身を以下のようにしている場合

something.cue
package something

something: {
}
package root

import "bucyou.net/something"

// something.something => {}
something.something

のような形で利用することができる。最初、cue.mod/pkg/* で定義していたモジュール名と、 cue.mod/module.cue で定義していたモジュール名がかぶってしまって、うまく cue.mod/pkg/* 以下のパッケージがインポートされないという苦しみを味わってしまった。。

ロード順序に関しては、こちらに記載してあるのでよく読むべし。。

ooharabucyouooharabucyou

さて、ここまでいろいろ基本的なものをやってきたので、そろそろ本題のOpenAPIの件に議論を変える。
cuelang そのものには、OpenAPI を出力するための仕組みが存在する。

とりあえず、これも使ってみようとは思うが、2つほど気になるポイントがある。

  • cue から作ってくれるのは、components.schemas が主である。
  • cue コマンドからは OpenAPI ファイルを作ることはできず、cuelang が用意している、golang 上で利用するAPIを使う必要がある。

どういうことだろうか?
少し触ってみる。普段 golang については、メインで使っているわけではないので、なにか間違っている事があるかもしれないが、ご了承いただきたい。

まず、公式ドキュメントの https://cuelang.org/docs/usecases/datadef/ が言うには、以下のような、cueファイルを作ることで、OpenAPI が作れる。ドキュメントには書いてなかったが、$version を指定することで、OpenAPI の info.version の値として利用される。

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

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

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

    // Max is the limit.
    max?: uint & <100
}
openapi.yml
{
   "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
               }
            }
         }
      }
   }
}

なかなか便利そうな予感はする。
ポイントとして、description は、コメント行で記すというルールになっている。

これを出力するためには、以下のようなコードを書いて実行する必要がある。

main.go
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 a.cue
# 先程、例示したJSONデータが表示される

Schema を作るだけであれば、これで良さそうな予感がするが、なんというかここにたどり着くまでが、面倒だし、ソースコードを読む限り、今の所、Paths を構成したりするのには対応してなさそうである。

となると、cuelangを利用して完全なものを作りたいという目的がある場合、方向性としては、上記のコードをベースに独自の encoder を作っていくか、純粋に cuelang の export 機能を使って、OpenAPI を構築していくか、ということになりそうである。

今回は、後者で考えていきたい。

ooharabucyouooharabucyou

OpenAPI ファイルを構築する前に、構築する際に必要になった各論を取り扱う。

cue を使うのだから、ある程度データ型に制約を与えていき、間違えがあることを事前に知りたいはずである。とはいえ、制約のかけ方が難しい場面もある。
そこで、もろもろのパターンを確認していく。

_ は TypeScript における any のようなもの

_ を使うと、どんな型でも受け入れられる状態になる。可能な限り使いたくはないが、たとえば OpenAPI でいうと、Schema Object の example の型は Any であると定義されており、このような状態に対応するために利用できる。

#a: {
  foo: _
}

a: #a & {
  foo: "OK"
}

b: #a & {
  foo: 3.14
}

c: #a & {
  foo: {
    something: "any string"
  }
}
a:
  foo: OK
b:
  foo: 3.14
c:
  foo:
    something: any string

List の制約

以下の用に List の制約を与えるときは [] の中に型を入れることで行うことができる。
ただし、[int] とすると、int を1要素のみしか許容されなくなる。

OK
#a: [int]

a: #a & [1]
a:
  - 1
NG
#a: [int]

a: #a & [1, 2]
a: incompatible list lengths (1 and 2)

[int, int] とすると、2要素、[int, int, int] とすると、3要素といった形で制約される。
たいてい、List はいくらでも定義できるというパターンが多いと思われるので、その場合は、[...int] のように記載する。
これにより、int をいくつでも定義可能。という制約になる。

OK
#a: [...int]

a: #a & [1, 2]
a:
  - 1
  - 2

さらに、cue がデフォルトで用意しているlistパッケージ を利用することで、配列の下限、上限などの制約を与えることもできる。OpenAPI作る場面においては、あまり使うことはないかななどと考えている。

OK
import "list"

#a: [...int] & list.MaxItems(2)

a: #a & [1, 2]
NG
import "list"

#a: [...int] & list.MaxItems(2)

a: #a & [1, 2, 3]
a: invalid value [1,2,3] (does not satisfy list.MaxItems(2)):
    -:3:16
    -:3:30
    -:5:4

Map<string, struct> のような定義したい

structの値側の制約は良かったが、キー側はどうだろうか? OpenAPI には、paths などを記載する際に、キーに、エンドポイントのパス、値に Path Object を記載する場面があるので、事前に定義されたキーしか記載できないとなると大いに問題がおきる。
そこで、以下のように書くことで、キーを自由に増やすことができるようになる。

#a: {
  [string]: {
    name: string
  }
}

a: #a & {
  "Kawahara": {
    name: "Kawahara"
  }
  "Yamada": {
     name: "Yamada"
  }
}
a:
  Kawahara:
    name: Kawahara
  Yamada:
    name: Yamada

フラットに struct を書く

以下のように、struct にフィールドが一つのみ場合は、省略形を記載することもできる。以下の2つのcueコードは等価である。

#a: {
  [string]: {
    name: string
  }
}
#a: [string]: name: string

| を使った struct の自動的な選択

以下のように、#type は、#type_a もしくは、#type_b があって、それは、type フィールドの値によって決定される。という場合については、struct どうしを | で区切ることより、自動選択される型を定義できる。

#type_a: {
  type: "a"
  description: string
}

#type_b: {
  type: "b"
  description: string
  url: string
}

#type: #type_a | #type_b

my_type: #type & {
  type: "b"
  description: "This is b type (url is required)"
  url: "http://www.bucyou.net"
}
my_type:
  type: b
  description: This is b type (url is required)
  url: http://www.bucyou.net

たとえば、この状態のときに、my_type の url を削除すると、#type_b の定義に合わなくなるのでエラーになる。

#type_a: {
  type: "a"
  description: string
}

#type_b: {
  type: "b"
  description: string
  url: string
}

#type: #type_a | #type_b

my_type: #type & {
  type: "b"
  description: "This is b type (url is required)"
}
my_type.url: incomplete value string:
    -:9:8

これらのテクニックをうまく駆使する。

ooharabucyouooharabucyou

というわけで、ようやく OpenAPI を作成する cue ファイルの作成を行う。最終的な目的として、ファイルやディレクトリを分けていい感じに管理していきたい思いがあるので、モジュールを利用することにする。

cue mod init "bucyou.net/openapi"

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

.
└── cue.mod
    ├── module.cue  # module: "bucyou.net/openapi"
    ├── pkg
    └── usr

最終的にこのような階層を作っていくつもりだ。コンセプトとしては、schema や、path を追加する際は、cueファイルを追加していくだけで、どんどんエンドポイントを増やしていける。というのを目指したい。

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

まず、definition ディレクトリを作成し、openapi.cue を配置する。
schema の内容など、まだ完全には対応できてないが、https://swagger.io/specification/ に記載されているものをベースに作られている。

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

次に、Schema を定義するために、component/schema/user.cue のようなファイルを作ってみる。
definition パッケージを使い、制約を与えていくと便利。

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"
	]
}

Path については、path/user.cue にようなファイルを作り定義していく。
YAMLだと階層のこととかを気にしてしまうが、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"
						}
					}
				}
			}
		}
	}
}

これらのパッケージをまとめ、OpenAPI Object を作るための root.cue を以下のように書いていく。
パッケージをインポートすると、path には、path ディレクトリにある情報をまとめて、struct を作ってくれる。

package root

// bucyou.net/openapi が プロジェクトルートのパスとして扱われる
import (
	"bucyou.net/openapi/path"
	"bucyou.net/openapi/definition"
	"bucyou.net/openapi/component/schema"
)

// definition.#opanapi は、definition/openapi.cue で定義されている
// openapi のバージョンは 3.0.3 で定義済みなので、ここで記載する必要はない
definition.#openapi & {
	info: {
		title:   "The sample API"
		version: "0.0.1"
	}
	servers: [
		{
			url:         "http://localhost:3000/v1"
			description: "local server"
		},
	]
        // ipmort した path パッケージを使う
	paths: path
	components: {
                 // import した component/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"
						}
					}
				}
			}
		}
	}
}

これで、YAML ファイルを作る場合は、cue export root.cue --out yaml を実行することで、以下のような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

たとえば、今後、記事管理をAPIで行いたいとなったとして、 Article のスキーマや、パスを定義したい場合は、user.cue と同じように、component/schema/article.cue, path/article.cue を定義するだけでよく、root.cue を弄る必要がないというメリットがある。

また、さらなる改善として、path/*.cue で、繰り返し記載するような情報は、cue の参照機能を使うことで、同じ項目を何度も書かなくてよくなるといったことができる。

path/security.cue を定義して、以下のような記載をする。

package path

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

#oauth2_write: {
	OAuth2: ["write"]
}
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"
							}
						}
					}
				}
			}
		}
	}
}

// 以下省略
ooharabucyouooharabucyou

ということで、まとめ

cuelang で OpenAPI ファイルを作っていくにあたってのメリットと、デメリットを考えてみる。個人的な感想も混ざっているため、一般的なものではないかもしれない。
(cuelang が用意している OpenAPI encoder を使わない場合)

メリット

  • 強力な型制約。より厳しい制約を OpenAPI に与えていくことも可能になる。
  • YAML や JSON が書きにくい問題の解消。
  • 繰り返し書かなくてはならない場面に対応しやすい。
  • ファイル構成などがわりと自由が効き、巨大化する OpenAPI ファイルに対応しやすい。(これは自由度がありすぎることによるデメリットにもなりうる)

デメリット

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

cuelang には、Attribute というメタな情報を定義することができる仕組みもあるので、これをうまく活用しライブラリとして提供されている、OpenAPI の Encoder をより進化させて、Schema 以外の定義にも対応したらよさそうだなぁという雰囲気もある。
go をもうちょっとやらんとなー。などと思った2022年GWであった。

このスクラップは2022/05/09にクローズされました