Skip to content

Instantly share code, notes, and snippets.

@deton
Last active August 27, 2023 05:20
Show Gist options
  • Save deton/26475483b6e6e1dca21628cc7ad98f04 to your computer and use it in GitHub Desktop.
Save deton/26475483b6e6e1dca21628cc7ad98f04 to your computer and use it in GitHub Desktop.
aspidaのapiディレクトリ構造とindex.tsをもとに、OpenAPI記述(JSON)を生成するための、簡易的なスクリプト

aspida2openapi

aspidaのapiディレクトリ構造とindex.tsをもとに、 OpenAPI記述(JSON)を生成するための、簡易的なスクリプトです。

ts-json-schema-generatorを使って、index.tsをJSON schemaに変換して、 OpenAPI記述を生成します。

生成物の以下の部分などを、手で変更して使う想定です。

  • ファイルアップロード・ダウンロード関係
  • レスポンスコード
  • "example"等の追加

制限事項/未対応項目

  • reqFormatは未対応なので無視されます。
  • request, responseとも"application/json"決め打ちです。 index.tsでFile | ReadStreamBuffer等で記述された ファイルアップロード/ダウンロードに関しては、出力されるOpenAPI jsonを手で 変更する必要があります。
  • 出力OpenAPI記述のHTTPレスポンスコードは200固定。 controller.ts側で201等で記述されている場合でも、 そこまで見ていないので、200になります。
  • openapi2aspida/samples/にある例を逆変換しても色々不完全です。

他の方法

  • @fastify/swagger(と@fasfity/swagger-ui)を使ってOpenAPI記述を取得。
    • パラメータ等は、FrourioのレスポンススキーマZodでのバリデーション で記述。
    • @fastify/swaggerはJSON schemaのみ対応なので、Zodを使っている場合は、fastify-type-provider-zodのjsonSchemaTransformを使ってtransformが必要。 transform: jsonSchemaTransform
      • Zod用のjsonSchemaTransformを使っている場合、レスポンススキーマもZodで指定する必要あり。
// aspida2openapi: make OpenAPI JSON from aspida api directory and index.ts
/* MIT License
Copyright (c) 2023 KIHARA, Hideto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
const fs = require('node:fs');
const path = require('node:path');
const util = require('node:util');
const tsj = require("ts-json-schema-generator");
const args = util.parseArgs({
options: {
input: {type: 'string'}, // input path of aspida api. default: 'api'
basePath: {type: 'string'}, // output base path. default: '/api'
}
});
const inputTop = args.values.input ?? 'api';
const basePath = args.values.basePath ?? '/api';
// from aspida
const valNameRegExpStr = '^_[a-zA-Z][a-zA-Z0-9_]*';
const valNameRegExp = new RegExp(valNameRegExpStr);
const valTypeRegExpStr = '(@number|@string)';
const valTypeRegExp = new RegExp(valTypeRegExpStr);
const tsjBaseConfig = {
tsconfig: "tsconfig.json", // for import '$/types'
type: "*",
};
const paths = {};
const outSchemas = {};
const outParams = {};
walk(inputTop);
const openapi = {
openapi: "3.1.0",
info: {
title: "",
version: "0.0.1",
},
paths,
components: {
schemas: outSchemas,
parameters: outParams,
}
};
console.log(JSON.stringify(openapi, null, 2)
.replace(/#\/definitions\//g, '#/components/schemas/'));
function walk(input) {
fs.readdirSync(input, { withFileTypes: true })
.forEach(dirent => {
if (dirent.isDirectory()) {
walk(`${input}/${dirent.name}`);
} else if (dirent.name == 'index.ts') {
const config = tsjBaseConfig;
config.path = `${input}/${dirent.name}`;
const schema = tsj.createGenerator(config).createSchema(config.type);
convertSchema(config.path, schema);
}
});
}
function convertSchema(fname, input) {
Object.keys(input.definitions).forEach(name => {
if (name != 'Methods') { // skip API definition
outSchemas[name] = input.definitions[name];
}
});
// convert API definition
if (!('Methods' in input.definitions)) {
return;
}
const pathParams = [];
const apipath = path.dirname(fname).split('/').slice(1).map(basename => {
if (!basename.startsWith('_')) {
return basename;
}
// ex: /user/_userId@string -> /user/{userId}
const valName = basename.match(valNameRegExp)[0].substring(1);
const valType = basename.replace('_' + valName, '').startsWith('@')
? basename.split('@')[1].slice(0, 6)
: ["number", "string"]; // default: number | string
pathParams.push({
"name": valName,
"in": "path",
"required": true,
"schema": { "type": valType }
});
return `{${valName}}`;
}).join('/');
const apath = path.join(basePath, apipath);
paths[apath] = {};
for (const methodname of Object.keys(input.definitions.Methods.properties)) {
const method = input.definitions.Methods.properties[methodname];
paths[apath][methodname] = {"description": method.description};
if (pathParams.length > 0) {
paths[apath][methodname].parameters = [...pathParams]; // make copy
}
// XXX: should get response status code "200" from controller.ts
let statuscode = "200";
for (const propname of Object.keys(method.properties)) {
try {
switch (propname) {
case 'reqHeaders':
paths[apath][methodname].parameters ??= [];
paths[apath][methodname].parameters.push(...convParams(method.properties.reqHeaders, 'header'));
break;
case 'query':
paths[apath][methodname].parameters ??= [];
paths[apath][methodname].parameters.push(...convParams(method.properties.query, 'query'));
break;
case 'reqBody':
paths[apath][methodname].requestBody = {
"description": method.properties.reqBody.description,
"content": {
"application/json": { // TODO: check reqFormat
"schema": convForSchema(method.properties.reqBody)
}
}
};
break;
case 'resBody':
paths[apath][methodname].responses ??= {};
paths[apath][methodname].responses[statuscode] ??= {};
paths[apath][methodname].responses[statuscode].description = method.properties.resBody.description ?? "";
paths[apath][methodname].responses[statuscode].content = {
"application/json": { // XXX
"schema": convForSchema(method.properties.resBody)
}
};
break;
case 'resHeaders':
paths[apath][methodname].responses ??= {};
paths[apath][methodname].responses[statuscode] ??= {};
const resHeaderProps = method.properties.resHeaders.properties;
Object.keys(resHeaderProps).forEach(name => {
resHeaderProps[name].schema = {"type": resHeaderProps[name].type};
delete resHeaderProps[name].type;
});
paths[apath][methodname].responses[statuscode].headers = resHeaderProps;
break;
case 'status':
const newcode = '' + method.properties.status.const;
if (paths[apath][methodname].responses) {
// use specified status code instead of default code
if (newcode != statuscode) {
paths[apath][methodname].responses[newcode] = paths[apath][methodname].responses[statuscode];
delete paths[apath][methodname].responses[statuscode];
}
} else {
paths[apath][methodname].responses = {};
paths[apath][methodname].responses[newcode] = {
description: "",
};
}
statuscode = newcode;
break;
default:
console.warn(`Not supported: '${propname}' in ${fname}: `, method.properties[propname]);
break;
}
} catch (err) {
console.error(`error for '${propname}' in ${fname}: `, method.properties[propname]);
throw err;
}
}
}
function convForSchema(obj) {
delete obj.description;
return obj;
}
function convParams(obj, invalue) {
if (!('$ref' in obj)) {
// "reqHeaders": {"properties": {"x-hdr": {"type": "string"}}}
return genParams(obj.properties);
}
// convert #/definitions/XXX to #/components/parameters/ format.
const refkey = path.basename(obj['$ref']); // "#/definitions/XXXHeader"
// "XXXHeader": {"properties": {"x-hdr": {"type": "string"}}}
outParams[refkey] = genParams(input.definitions[refkey].properties)[0];
delete outSchemas[refkey]; // TODO: prevent adding again
obj['$ref'] = obj['$ref'].replace(/#\/definitions\//, '#/components/parameters/');
return [obj];
function genParams(props) {
// ->
// [{"name": "x-hdr", "in": "header", "schema": {"type": "string"}}]
return Object.keys(props).map(name => {
return {
"name": name,
"in": invalue,
"description": props[name].description,
"schema": convForSchema(props[name])
};
});
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment