cue言語でOpenAPIのYAMLファイルを生成したい
OpenAPI の YAML ファイルを作成するのは辛い。これは OpenAPI の問題というよりは、JSON、YAMLの問題と言ったほうがいいかも知れない。
辛いポイントはだいたい @suin さんが、言っている通り。
他の問題として、ファイル分割の問題というのがある。
OpenAPI の YAML のファイル分割については、以下などで論じられている。
ただ、$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 を使うかはまだ未決定である。
インストールは Mac であれば brew から行うことができる。
brew install cue-lang/tap/cue
もしくは、以下から各プラットフォーム別のバイナリを入手できる。
ちょっと試したいだけであれば、https://cuelang.org/play/#cue@export@cue を使うことでインストールしなくても実験できる。
とりあえず、cue を書くにあたって、IntelliJ に CUE Plugin を入れる。
はじめの一歩
hello: {
world: "hoge"
}
とりあえず、こんなものを書いてみる。
cue export で --out yaml
を指定すると、yaml が生成される。
cue export main.cue --out yaml
hello:
world: hoge
コメントは、C言語のように、//
で記載できる。
hello: {
// comment
world: "hoge"
}
JSONや、YAMLと違って良いポイントとしては、一度記載したオブジェクトに、値をマージすることができる点。
hello: {
world: "hoge"
}
hello: {
hoge: "fuga"
}
cue export main.cue --out yaml
hello:
world: hoge
hoge: fuga
他のオブジェクトの値の参照ができる
foo: {
bar: "hoge"
}
hello: {
world: foo.bar
}
foo:
bar: hoge
hello:
world: hoge
alias として、参照を変数として定義することもできる。
順序は関係ないので、let A = a を b: {} の下に移動しても動作する。
let A = a
a: {
d: 3
}
b: {
a: {
c: A.d
}
}
a:
d: 3
b:
a:
c: 3
キーの最初に、#
もしくは _#
で始めることで、定義を行うことができる。
型としては以下を利用することができる。
- 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
以下のコードのように、最初の段階では定義されたものを用意していなくても、最終的に用意できれば問題がない。
#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
フィールド名に ?
を付与することで、あってもなくても問題がない値になる。
#Task: {
name: string
rank?: int
}
go_to_park: #Task & {
name: "公園に行く"
}
go_to_park:
name: 公園に行く
定義で |
を利用することで、いずれか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
定義のとき *
を値の前につけることで、最終的にどの値にも決定されなかったときのデフォルト値を設定することができる。
#Task: {
name: string
rank: int | *1
}
go_to_park: #Task & {
name: "公園に行く"
}
go_to_park:
name: 公園に行く
rank: 1
数値・文字列・バイナリと 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
特によく利用する整数の範囲については、あらかじめ定義されたものが存在する。
- 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
template 機構というものがあって、予め用意された構造を使い回すことができる。
なかなか強力そうな機能。
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: ""
では、ファイルを分けたいときはどうするか。yaml 自体にはファイルを外部から読み込む機能はない。
OpenAPI には、 $ref
による参照を行う機能は存在するがどうもしっくりこないことは最初で説明したとおりである。
cue は複数ファイルがあっても、最終的に何かに収束すれば良いので、以下のように表現できる。
a: {
message: "This is message"
}
a: {
hello: "world"
}
cue export a.cue b.cue --out yaml
a:
message: This is message
hello: world
ただし、ディレクトリの中にある全てのファイルを読み込む。といったケースには package
を使う必要がある。(次の更新で確認)
また、ディレクトリの中に、さらに別のディレクトリを作って階層を管理するといったケースになると、モジュールの仕組みを使う必要がある。これが cuelang のなかなか面倒だと感じた部分。(慣れてないだけかもだけど、なんだか不自然と感じた。)
ディレクトリ内のすべてのcueファイルを読み込み評価するには、package
を使う必要がある。
以下の2つのファイルを同じディレクトリに入れる。
package main
a: {
}
package main
b: {
}
そして、カレントディレクトリを a.cue, b.cue が配置してある場所に移動して、export してみる。
cue export . --out yaml
a: {}
b: {}
無事に、a.cue, b.cue を評価して、変換した結果が出力される。
なお、同じディレクトリに
package main2
c: {
}
を配置すると、以下のようなエラーにより評価をしてくれない。
found packages "main" (a.cue) and main2 (c.cue) in "/foo/bar"
個人的にうーん微妙とおもったパッケージの import
について。
import
は、自分のパッケージ以外のなにか (構造だったり、定義だったり)を読み取って利用するための機構である。
あらかじめ、いくつかの機能については、組み込み機能として備えられている。 例えば、とある値をIPv4アドレスに限定したい場合は、以下のように記すことができる。
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 のアドレスとしてありえない場合を弾くことができる。
ほかにも、定数として円周率を出力したり
import "math"
pi: math.Pi
pi: 3.14159265358979323846264338327950288419716939937510582097494459
リストの合計値を出したりと、いろいろ機能はある。
import "list"
sum: list.Sum([1, 2, 3, 4, 5])
sum: 15
ここまでは便利そう(?) だが、問題は独自のファイルを読み込みたいパターンであった。
まず、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
というファイルを置き、読み込みを試してみる。
package bar
bar: {
hello: "world"
}
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
の中身を以下のようにしている場合
package something
something: {
}
package root
import "bucyou.net/something"
// something.something => {}
something.something
のような形で利用することができる。最初、cue.mod/pkg/*
で定義していたモジュール名と、 cue.mod/module.cue
で定義していたモジュール名がかぶってしまって、うまく cue.mod/pkg/*
以下のパッケージがインポートされないという苦しみを味わってしまった。。
ロード順序に関しては、こちらに記載してあるのでよく読むべし。。
さて、ここまでいろいろ基本的なものをやってきたので、そろそろ本題の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 の値として利用される。
// Title
$version: "1.0"
// Info describes...
#Info: {
// Name of the adapter.
name: string
// Templates.
templates?: [...string]
// Max is the limit.
max?: uint & <100
}
{
"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
は、コメント行で記すというルールになっている。
これを出力するためには、以下のようなコードを書いて実行する必要がある。
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 を構築していくか、ということになりそうである。
今回は、後者で考えていきたい。
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要素のみしか許容されなくなる。
#a: [int]
a: #a & [1]
a:
- 1
#a: [int]
a: #a & [1, 2]
a: incompatible list lengths (1 and 2)
[int, int]
とすると、2要素、[int, int, int]
とすると、3要素といった形で制約される。
たいてい、List はいくらでも定義できるというパターンが多いと思われるので、その場合は、[...int]
のように記載する。
これにより、int
をいくつでも定義可能。という制約になる。
#a: [...int]
a: #a & [1, 2]
a:
- 1
- 2
さらに、cue がデフォルトで用意しているlistパッケージ を利用することで、配列の下限、上限などの制約を与えることもできる。OpenAPI作る場面においては、あまり使うことはないかななどと考えている。
import "list"
#a: [...int] & list.MaxItems(2)
a: #a & [1, 2]
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
これらのテクニックをうまく駆使する。
というわけで、ようやく 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/ に記載されているものをベースに作られている。
次に、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"
}
}
}
}
}
}
}
}
// 以下省略
ということで、まとめ
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であった。