|
// 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]) |
|
}; |
|
}); |
|
} |
|
} |
|
} |