はじめに
ユニットテストとかを書くためにAPIのリクエストとレスポンスがJSON形式で欲しいとなったときに、今まではOpenAPIドキュメントをReDocで表示してそこに載っているサンプルをコピー&JSON形式で保存し直すみたいなことをしていました。
ただしこの方法は、APIの数が増えてきてかつ変更もちょくちょくあるような場合だと結構面倒な作業ですし、CIにも組み込みにくいです。
わざわざこの画面を経由しないでもOpenAPIドキュメントのyamlからこのrequestBody/responsesだけを直接JSONで吐き出す方法は何か無いものかと色々調べてみたのですが、元のOpenAPIドキュメントをyaml ⇆ JSONで変換する類の話しか見つからなかったので簡単なスクリプトを書くことにしました。
(もしかしたら自分の調査不足なだけで実は既にこの手のことをローカルでやってくれるツールはあるのかもしれません...)
JSON出力スクリプト
やりたいこととしては以下のような感じです
- コマンドライン引数でOpenAPI (Swagger) 形式のyamlファイルを渡すとそのファイル内の全APIのrequestBody/responsesをJSON形式で出力する
- GETなどrequestBodyが無い場合は空のJSONを出力する
- responsesはステータスコード毎にJSONを出力する
理想はReDocのコピーボタンをクリックしたときにクリップボードに保存されるのと同じような形式のJSONを取得することだったので、当該処理がどうなっているのかソースコードをちょっと読んでみたところ、openapi-samplerというのを使ってそうだということが分かりました。
今回はそれを利用するためにスクリプトはNode.jsで書いています。
完成したスクリプトはこんな感じです。
package.jsonとか含めたコードはGitHubで公開しています。
https://github.com/NatsuToku/openapi-sample-json-generator
// ファイル出力の設定constinputFile=process.argv.length==3?process.argv[2]:"openapi.yaml";constoutputBasePath="./output";constoutputRequestJSONName="request";constoutputResponseJSONPrefix="response";constJSONSpaceNum=4;// パース方法の設定constmediaType="application/json";constskipNonRequired=false;constskipReadOnly=true;constskipWriteOnly=false;// モジュールのimportconstSwaggerParser=require("@apidevtools/swagger-parser");constOpenAPISampler=require("openapi-sampler");constfs=require("fs");(async()=>{// $refポインタを含まないOpenAPI定義のオブジェクトを取得するconstparser=awaitSwaggerParser.dereference(inputFile);// APIのパス毎に処理するObject.keys(parser.paths).forEach(function(path){// 同じパスのメソッド毎に処理するObject.keys(parser.paths[path]).forEach(function(method){// ファイル出力用のパスをAPIのパスとメソッドに基づいて設定する// ex) パスが /a/b/c でメソッドが POST -> /outputBasePath/a_b_c/postconstoutputPath=`${outputBasePath}/${path.replace("/","").replace(/\//g,"_")}/${method}`;// outputBasePath内にファイル出力用のディレクトリを作成するfs.mkdir(outputPath,{recursive:true},(err)=>{constapi=parser.paths[path][method];// requestBodyが存在している場合はサンプルJSONオブジェクトを生成するletrequestSample={};if(api.hasOwnProperty("requestBody")&&api.requestBody.hasOwnProperty(mediaType)){requestSample=OpenAPISampler.sample(api.requestBody.content[mediaType].schema,{skipNonRequired:skipNonRequired,skipReadOnly:skipReadOnly,skipWriteOnly:skipWriteOnly,});}// requestBodyのJSONを出力する (requestBodyが存在しない場合は空)fs.writeFileSync(`${outputPath}/${outputRequestJSONName}.json`,JSON.stringify(requestSample,null,JSONSpaceNum));// ステータスコード毎に処理するconstresponses=api.responses;Object.keys(responses).forEach(function(status){// responseのcontentが存在している場合はサンプルJSONオブジェクトを生成するletresponseSample={};if(responses[status].hasOwnProperty("content")&&responses[status].content.hasOwnProperty(mediaType)){responseSample=OpenAPISampler.sample(responses[status].content[mediaType].schema,{skipNonRequired:skipNonRequired,skipReadOnly:skipReadOnly,skipWriteOnly:skipWriteOnly,});}// responsesのJSONを出力する (responseのcontentが存在しない場合は空)fs.writeFileSync(`${outputPath}/${outputResponseJSONPrefix}_${status}.json`,JSON.stringify(responseSample,null,JSONSpaceNum));});});});});})();
実行結果
以下のコマンドで実行します。
node ./generator.js <OPENAPI_FILE_NAME>
引数のファイル名は省略するとデフォルトでは「./openapi.yaml」を読むようにしています。
yaml形式でしかテストしてませんがswagger-parserが対応しているのでおそらくJSON形式でも大丈夫だと思われます。
例えば以下のようなyamlを読ませた場合(内容は適当です)
openapi:3.0.0info:title:"BookAPI"version:"1.0"servers:-url:"https://xxxxxx.com"paths:/book:get:summary:Get book listdescription:"本の一覧を取得する"responses:200:description:"本の一覧"content:application/json:schema:type:"array"items:$ref:"#/components/schemas/BookIndex"400:description:Bad Requestcontent:application/json:schema:$ref:"#/components/schemas/BadRequest"post:summary:Create bookdescription:"本の情報を新規登録する"requestBody:content:application/json:schema:$ref:"#/components/schemas/Book"responses:200:description:"OK"content:application/json:schema:$ref:"#/components/schemas/BookIndex"400:description:Bad Requestcontent:application/json:schema:$ref:"#/components/schemas/BadRequest"/book/{id}:get:summary:Get book detaildescription:"本の詳細を取得する"parameters:-name:idin:pathdescription:"uniquekey"required:trueschema:type:integerresponses:200:description:"本の一覧"content:application/json:schema:$ref:"#/components/schemas/BookIndex"400:description:Bad Requestcontent:application/json:schema:$ref:"#/components/schemas/BadRequest"delete:summary:Delete bookdescription:"本の登録を削除する"parameters:-name:idin:pathdescription:"uniquekey"required:trueschema:type:integerresponses:200:description:OK400:description:Bad Requestcontent:application/json:schema:$ref:"#/components/schemas/BadRequest"components:schemas:Author:type:objectrequired:-nameproperties:name:type:stringexample:"test"age:type:numberexample:30gender:type:stringexample:"unknown"Book:type:objectrequired:-title-price-authorsproperties:title:type:stringexample:"booktitle"price:type:numberexample:1500authors:type:arrayitems:$ref:"#/components/schemas/Author"category:type:"string"description:"bookcategory"example:"horror"BookIndex:allOf:-type:objectproperties:id:type:integerdescription:"uniquekey"-$ref:"#/components/schemas/Book"BadRequest:type:objectrequired:-messageproperties:message:type:stringexample:"error"
こんな感じに出力されます
output
├──book
│ ├── get
│ │ ├── request.json
│ │ ├── response_200.json
│ │ └── response_400.json
│ └─── post
│ ├── request.json
│ ├── response_200.json
│ └── response_400.json
└──book_{id}├── delete
│ ├── request.json
│ ├── response_200.json
│ └── response_400.json
└─── get
├── request.json
├── response_200.json
└── response_400.json
例えば/bookのPOSTのresponse_200.jsonの中身はこうなってます
{"id":0,"title":"book title","price":1500,"authors":[{"name":"test","age":30,"gender":"unknown"}],"category":"horror"}
コード内容の説明
// ファイル出力の設定constinputFile=process.argv.length==3?process.argv[2]:"openapi.yaml";constoutputBasePath="./output";constoutputRequestJSONName="request";constoutputResponseJSONPrefix="response";constJSONSpaceNum=4;// パース方法の設定constmediaType="application/json";constskipNonRequired=false;constskipReadOnly=true;constskipWriteOnly=false;
以下の内容を設定しています。
inputFile
: OpenAPI (Swagger) 形式のファイル。コマンドライン引数で指定。なければデフォルト「openaip.yaml」outputBasePath
:JSONファイルを出力するディレクトリoutputRequestJSONName
:requestBodyのJSONを出力するときのファイル名。この場合だと「request.json」outputResponseJSONPrefix
:responsesのcontentのJSONを出力するときのファイル名のプレフィックス。この場合だと「response_<status_code>.json」JSONSpaceNum
:出力するJSONファイルのインデントのスペース数mediaType
:OpenAPIをパースするときに対象とするメディアタイプ。"application/json"固定で良さそうskipNonRequired
:required
がtrueのパラメータをスキップする(JSONで出力しない)かどうかskipReadOnly
:readOnly
がtrueのパラメータをスキップする(JSONで出力しない)かどうかskipWriteOnly
:writeOnly
がtrueのパラメータをスキップする(JSONで出力しない)かどうか
// モジュールのimportconstSwaggerParser=require("@apidevtools/swagger-parser");constOpenAPISampler=require("openapi-sampler");constfs=require("fs");
@apidevtools/swagger-parser
- OpenAPI (Swagger) 形式のファイルを渡すとrefの解決をした状態のオブジェクトを返してくれる
- 素でyamlファイルを開いてしまうとrefもそのままになってしまうので使用
openapi-sampler
- OpenAPI Schemaオブジェクトを渡すとサンプルのJSONオブジェクトを返してくれる
- ReDocでは内部的にこれを利用している模様
fs
- ファイル関係の操作で使用
(async()=>{// $refポインタを含まないOpenAPI定義のオブジェクトを取得するconstparser=awaitSwaggerParser.dereference(inputFile);
swagger-parserがawaitな関数なのでasync functionで囲んだ上で入力ファイルから $refポインタを含まないOpenAPI定義のオブジェクトを取得しています。
// APIのパス毎に処理するObject.keys(parser.paths).forEach(function(path){// 同じパスのメソッド毎に処理するObject.keys(parser.paths[path]).forEach(function(method){
OpenAPIのpathsの中の各メソッド毎に処理します。
このforEach内でさらにforEatchを回す書き方はなんとなくもっと良い書き方がある気がしています...
// ファイル出力用のパスをAPIのパスとメソッドに基づいて設定する// ex) パスが /a/b/c でメソッドが POST -> /outputBasePath/a_b_c/postconstoutputPath=`${outputBasePath}/${path.replace("/","").replace(/\//g,"_")}/${method}`;
JSONファイルを出力するパスは、OpenAPIのpathsとmethodで決めています。
pathはそのままだと「/a/b/c
」みたいな形式なので、最初の「/」は消して、残りの「/」は全て「_」に置換することで「 a_b_c
」みたいなディレクトリになり、さらにその下にgetやpostと言ったディレクトリを作ります。
// outputBasePath内にファイル出力用のディレクトリを作成するfs.mkdir(outputPath,{recursive:true},(err)=>{constapi=parser.paths[path][method];
先ほど定義したoutputPathのディレクトリを作成します。
recursiveをtrueにすることで再帰的に作成。
既にディレクトリがあるとerrになるのですが、ここではerrが発生してもそのまま握り潰してます。
(のでerr変数はこの後使われない)
// requestBodyが存在している場合はサンプルJSONオブジェクトを生成するletrequestSample={};if(api.hasOwnProperty("requestBody")&&api.requestBody.hasOwnProperty(mediaType)){requestSample=OpenAPISampler.sample(api.requestBody.content[mediaType].schema,{skipNonRequired:skipNonRequired,skipReadOnly:skipReadOnly,skipWriteOnly:skipWriteOnly,});}// requestBodyのJSONを出力する (requestBodyが存在しない場合は空)fs.writeFileSync(`${outputPath}/${outputResponseJSONPrefix}_${status}.json`,JSON.stringify(requestSample,null,JSONSpaceNum));
apiにrequestBodyが含まれかつmediaTypeがapplication/jsonだったらopenapi-samplerでサンプルのオブジェクトを作成し、その後そのオブジェクトをJSONファイルとして出力します。
GETのようにrequestBodyが無いような場合は空のJSONを出力します。
(出力したくなければwriteFileSyncの前に条件判断を入れれば良さそう)
// ステータスコード毎に処理するconstresponses=api.responses;Object.keys(responses).forEach(function(status){// responseのcontentが存在している場合はサンプルJSONオブジェクトを生成するletresponseSample={};if(responses[status].hasOwnProperty("content")&&responses[status].content.hasOwnProperty(mediaType)){responseSample=OpenAPISampler.sample(responses[status].content[mediaType].schema,{skipNonRequired:skipNonRequired,skipReadOnly:skipReadOnly,skipWriteOnly:skipWriteOnly,});}// responsesのJSONを出力する (responseのcontentが存在しない場合は空)fs.writeFileSync(`${outputPath}/response_${status}.json`,JSON.stringify(responseSample,null,JSONSpaceNum)
requestBodyと同様にcontentが含まれかつmediaTypeがapplication/jsonだったらopenapi-samplerでサンプルのオブジェクトを作成し、その後そのオブジェクトをJSONファイルとして出力します。
requestBodyとの違いとしてはresponsesはステータスコード毎に複数設定可能であるため、処理もステータスコードをforEatchで回しています。
また、出力するファイル名のサフィックスにステータスコードをくっつけるようにしています。
最後に
普段Node.jsはあまり書いてない+こういうスクリプト的なものは初めて書いたのでコマンドライン引数の取り方とかファイルの保存方法とか個人的には勉強になりました。
Node.jsの書き方のベストプラクティス的なことを把握しないでフィーリングで書いている部分が多いので、ここの書き方微妙だよ的な指摘があればバンバンしていただけるとありがたいです。
参考
OpenAPI Specification - Version 3.0.3 | Swagger
GitHub - Redocly/openapi-sampler: Tool for generation samples based on OpenAPI(fka Swagger) payload/response schema
Swagger 2.0 and OpenAPI 3.0 parser/validator | Swagger Parser
Node.jsでコマンドライン引数を取得する - Qiita
[Node.js]ディレクトリの作成と削除をする
How can I pretty-print JSON using node.js? - Stack Overflow