generate_openapi.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. #!/usr/bin/env node
  2. /*
  3. Node.js port of openapi/generate_openapi.py (minimal, Node 14 compatible).
  4. Parses models to produce an OpenAPI 2.0 YAML on stdout.
  5. */
  6. 'use strict';
  7. const fs = require('fs');
  8. const path = require('path');
  9. const esprima = require('esprima');
  10. function cleanupJsdocs(jsdoc) {
  11. const lines = jsdoc.value.split('\n')
  12. .map(s => s.replace(/^\s*/, ''))
  13. .map(s => s.replace(/^\*/, ''));
  14. while (lines.length && !lines[0].trim()) lines.shift();
  15. while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
  16. return lines;
  17. }
  18. function loadReturnTypeJsdocJson(data) {
  19. let s = data;
  20. const repl = [
  21. [/\n/g, ' '],
  22. [/([\{\s,])(\w+)(:)/g, '$1"$2"$3'],
  23. [/(:)\s*([^:\},\]]+)\s*([\},\]])/g, '$1"$2"$3'],
  24. [/([\[])\s*([^\{].+)\s*(\])/g, '$1"$2"$3'],
  25. [/^\s*([^\[{].+)\s*/, '"$1"']
  26. ];
  27. for (const [r, rep] of repl) s = s.replace(r, rep);
  28. try { return JSON.parse(s); } catch { return data; }
  29. }
  30. class Context {
  31. constructor(filePath) {
  32. this.path = filePath;
  33. this._txt = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
  34. const data = this._txt.join('\n');
  35. this.program = esprima.parseModule(data, { comment: true, loc: true, range: true });
  36. }
  37. textAt(begin, end) { return this._txt.slice(begin - 1, end).join('\n'); }
  38. }
  39. function parseFile(filePath) {
  40. try { return new Context(filePath); } catch { return undefined; }
  41. }
  42. function getReqBodyElems(node, acc) {
  43. if (!node) return '';
  44. switch (node.type) {
  45. case 'FunctionExpression':
  46. case 'ArrowFunctionExpression': return getReqBodyElems(node.body, acc);
  47. case 'BlockStatement': node.body.forEach(s => getReqBodyElems(s, acc)); return '';
  48. case 'TryStatement': return getReqBodyElems(node.block, acc);
  49. case 'ExpressionStatement': return getReqBodyElems(node.expression, acc);
  50. case 'MemberExpression': {
  51. const left = getReqBodyElems(node.object, acc);
  52. const right = node.property && node.property.name;
  53. if (left === 'req.body' && right && !acc.includes(right)) acc.push(right);
  54. return `${left}.${right}`;
  55. }
  56. case 'VariableDeclaration': node.declarations.forEach(s => getReqBodyElems(s, acc)); return '';
  57. case 'VariableDeclarator': return getReqBodyElems(node.init, acc);
  58. case 'Property': return getReqBodyElems(node.value, acc);
  59. case 'ObjectExpression': node.properties.forEach(s => getReqBodyElems(s, acc)); return '';
  60. case 'CallExpression': node.arguments.forEach(s => getReqBodyElems(s, acc)); return '';
  61. case 'ArrayExpression': node.elements.forEach(s => getReqBodyElems(s, acc)); return '';
  62. case 'IfStatement': getReqBodyElems(node.test, acc); if (node.consequent) getReqBodyElems(node.consequent, acc); if (node.alternate) getReqBodyElems(node.alternate, acc); return '';
  63. case 'LogicalExpression':
  64. case 'BinaryExpression':
  65. case 'AssignmentExpression': getReqBodyElems(node.left, acc); getReqBodyElems(node.right, acc); return '';
  66. case 'ChainExpression': return getReqBodyElems(node.expression, acc);
  67. case 'ReturnStatement':
  68. case 'UnaryExpression': if (node.argument) return getReqBodyElems(node.argument, acc); return '';
  69. case 'Identifier': return node.name;
  70. default: return '';
  71. }
  72. }
  73. class EntryPoint {
  74. constructor(schema, [method, pathLit, body]) {
  75. this.schema = schema;
  76. this.method = method; this._path = pathLit; this.body = body;
  77. this._rawDoc = null; this._doc = {}; this._jsdoc = null;
  78. this.path = (this._path.value || '').replace(/\/$/, '');
  79. this.method_name = (this.method.value || '').toLowerCase();
  80. this.body_params = [];
  81. if (['post','put'].includes(this.method_name)) getReqBodyElems(this.body, this.body_params);
  82. let url = this.path.replace(/:([^\/]*)Id/g, '{$1}').replace(/:([^\/]*)/g, '{$1}');
  83. this.url = url;
  84. const tokens = url.split('/');
  85. const reduced = [];
  86. for (let i = 0; i < tokens.length; i++) {
  87. const t = tokens[i];
  88. if (t === 'api') continue;
  89. if (i < tokens.length - 1 && tokens[i+1].startsWith('{')) continue;
  90. reduced.push(t.replace(/[{}]/g, ''));
  91. }
  92. this.reduced_function_name = reduced.join('_');
  93. schema.used = true;
  94. }
  95. set doc(doc) { this._rawDoc = doc; this._jsdoc = cleanupJsdocs(doc); this._doc = this.parseDoc(); }
  96. get doc() { return this._doc; }
  97. parseDoc() {
  98. if (!this._jsdoc) return {};
  99. const result = {};
  100. let currentTag = 'description';
  101. let current = '';
  102. const store = (tag, data) => {
  103. if (data == null) return; const s = (data + '').replace(/\s+$/,''); if (!s) return;
  104. if (tag === 'param') {
  105. result.params = result.params || {};
  106. let nameDesc = s.trim();
  107. let paramType = null;
  108. let name = nameDesc; let desc = '';
  109. const mType = nameDesc.match(/^\{([^}]+)\}\s*(.*)$/);
  110. if (mType) { paramType = mType[1]; nameDesc = mType[2]; }
  111. const sp = nameDesc.split(/\s+/, 2); name = sp[0]; desc = sp[1] || '';
  112. const optional = /^\[.*\]$/.test(name); if (optional) name = name.slice(1,-1);
  113. result.params[name] = [paramType, optional, desc];
  114. if (name.endsWith('Id')) { const base = name.slice(0,-2); if (!result.params[base]) result.params[base] = [paramType, optional, desc]; }
  115. return;
  116. }
  117. if (tag === 'tag') { (result.tag = result.tag || []).push(s); return; }
  118. if (tag === 'return_type') { result[tag] = loadReturnTypeJsdocJson(s); return; }
  119. result[tag] = s;
  120. };
  121. for (const lineRaw of this._jsdoc) {
  122. let line = lineRaw;
  123. if (/^@/.test(line.trim())) {
  124. const parts = line.trim().split(/\s+/, 2);
  125. const tag = parts[0]; const rest = parts[1] || '';
  126. if (['@operation','@summary','@description','@param','@return_type','@tag'].includes(tag)) {
  127. store(currentTag, current);
  128. currentTag = tag.slice(1); current = ''; line = rest;
  129. }
  130. }
  131. current += line + '\n';
  132. }
  133. store(currentTag, current);
  134. return result;
  135. }
  136. get summary() { return this._doc.summary ? this._doc.summary.replace(/\n/g,' ') : null; }
  137. docParam(name) { const p = (this._doc.params||{})[name]; return p ? p : [null,null,null]; }
  138. operationId() { return this._doc.operation || `${this.method_name}_${this.reduced_function_name}`; }
  139. description() { return this._doc.description || null; }
  140. returns() { return this._doc.return_type || null; }
  141. tags() { const tags = []; if (this.schema.fields) tags.push(this.schema.name); if (this._doc.tag) tags.push(...this._doc.tag); return tags; }
  142. }
  143. class SchemaProperty {
  144. constructor(statement, schema, context) {
  145. this.schema = schema;
  146. this.statement = statement;
  147. this.name = (statement.key.name || statement.key.value);
  148. this.type = 'object'; this.blackbox = false; this.required = true; this.elements = [];
  149. (statement.value.properties || []).forEach(p => {
  150. try {
  151. const key = p.key && (p.key.name || p.key.value);
  152. if (key === 'type') {
  153. if (p.value.type === 'Identifier') this.type = (p.value.name || '').toLowerCase();
  154. else if (p.value.type === 'ArrayExpression') { this.type = 'array'; this.elements = (p.value.elements||[]).map(e => (e.name||'object').toLowerCase()); }
  155. } else if (key === 'blackbox') { this.blackbox = true; }
  156. else if (key === 'optional' && p.value && p.value.value) { this.required = false; }
  157. } catch(e) { /* ignore minor parse errors */ }
  158. });
  159. this._doc = null; this._raw_doc = null;
  160. }
  161. set doc(jsdoc){ this._raw_doc = jsdoc; this._doc = cleanupJsdocs(jsdoc); }
  162. get doc(){ return this._doc; }
  163. }
  164. class Schemas {
  165. constructor(context, statement, jsdocs, name) {
  166. this.name = name || null; this._data = statement; this.fields = null; this.used = false;
  167. if (statement) {
  168. if (!this.name) this.name = statement.expression.callee.object.name;
  169. const content = statement.expression.arguments[0].arguments[0];
  170. this.fields = (content.properties || []).map(p => new SchemaProperty(p, this, context));
  171. }
  172. this._doc = null; this._raw_doc = null;
  173. if (jsdocs) this.processJsdocs(jsdocs);
  174. }
  175. get doc(){ return this._doc ? this._doc.join(' ') : null; }
  176. set doc(jsdoc){ this._raw_doc = jsdoc; this._doc = cleanupJsdocs(jsdoc); }
  177. processJsdocs(jsdocs){
  178. if (!this._data) return;
  179. const start = this._data.loc.start.line, end = this._data.loc.end.line;
  180. for (const doc of jsdocs){ if (doc.loc.end.line + 1 === start) { this.doc = doc; break; } }
  181. const inRange = jsdocs.filter(doc => doc.loc.start.line >= start && doc.loc.end.line <= end);
  182. for (const f of (this.fields||[]))
  183. for (let i=0;i<inRange.length;i++){ const doc=inRange[i]; if (f.statement && f.statement.loc && f.statement.loc.start && f.statement.loc.start.line === (f.statement.key && f.statement.key.loc && f.statement.key.loc.start.line)) { f.doc = doc; inRange.splice(i,1); break; } }
  184. }
  185. }
  186. function parseSchemas(schemasDir){
  187. const schemas = {}; const entryPoints = [];
  188. const walk = dir => {
  189. const items = fs.readdirSync(dir, { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name));
  190. for (const it of items){
  191. const p = path.join(dir, it.name);
  192. if (it.isDirectory()) walk(p);
  193. else if (it.isFile()){
  194. const context = parseFile(p); if (!context) continue; const program = context.program;
  195. let currentSchema = null;
  196. const jsdocs = (program.comments||[]).filter(c => c.type === 'Block' && c.value.startsWith('*\n'));
  197. for (const statement of program.body){
  198. try {
  199. if (statement.type === 'ExpressionStatement' && statement.expression && statement.expression.callee && statement.expression.callee.property && statement.expression.callee.property.name === 'attachSchema' && statement.expression.arguments[0] && statement.expression.arguments[0].type === 'NewExpression' && statement.expression.arguments[0].callee.name === 'SimpleSchema'){
  200. const schema = new Schemas(context, statement, jsdocs);
  201. currentSchema = schema.name; schemas[currentSchema] = schema;
  202. } else if (statement.type === 'IfStatement' && statement.test && statement.test.type === 'MemberExpression' && statement.test.object && statement.test.object.name === 'Meteor' && statement.test.property && statement.test.property.name === 'isServer'){
  203. const conseq = statement.consequent && statement.consequent.body || [];
  204. const data = conseq.filter(s => s.type === 'ExpressionStatement' && s.expression && s.expression.type === 'CallExpression' && s.expression.callee && s.expression.callee.object && s.expression.callee.object.name === 'JsonRoutes').map(s => s.expression.arguments);
  205. if (data.length){
  206. if (!currentSchema){ currentSchema = path.basename(p); schemas[currentSchema] = new Schemas(context, null, null, currentSchema); }
  207. const eps = data.map(d => new EntryPoint(schemas[currentSchema], d)); entryPoints.push(...eps);
  208. let endOfPrev = -1;
  209. for (const ep of eps){
  210. const op = ep.method; const prior = jsdocs.filter(j => j.loc.end.line + 1 <= op.loc.start.line && j.loc.start.line > endOfPrev);
  211. if (prior.length) ep.doc = prior[prior.length - 1];
  212. endOfPrev = op.loc.end.line;
  213. }
  214. }
  215. }
  216. } catch(e){ /* ignore parse hiccups per file */ }
  217. }
  218. }
  219. }
  220. };
  221. walk(schemasDir);
  222. return { schemas, entryPoints };
  223. }
  224. function printOpenapiReturn(obj, indent){
  225. const pad = ' '.repeat(indent);
  226. if (Array.isArray(obj)){
  227. console.log(`${pad}type: array`); console.log(`${pad}items:`); printOpenapiReturn(obj[0], indent+2); return;
  228. }
  229. if (obj && typeof obj === 'object'){
  230. console.log(`${pad}type: object`); console.log(`${pad}properties:`);
  231. for (const k of Object.keys(obj)){ console.log(`${pad} ${k}:`); printOpenapiReturn(obj[k], indent+4); }
  232. return;
  233. }
  234. if (typeof obj === 'string') console.log(`${pad}type: ${obj}`);
  235. }
  236. function generateOpenapi(schemas, entryPoints, version){
  237. console.log(`swagger: '2.0'
  238. info:
  239. title: Wekan REST API
  240. version: ${version}
  241. description: |
  242. The REST API allows you to control and extend Wekan with ease.
  243. schemes:
  244. - http
  245. securityDefinitions:
  246. UserSecurity:
  247. type: apiKey
  248. in: header
  249. name: Authorization
  250. paths:
  251. /users/login:
  252. post:
  253. operationId: login
  254. summary: Login with REST API
  255. consumes:
  256. - application/json
  257. - application/x-www-form-urlencoded
  258. tags:
  259. - Login
  260. parameters:
  261. - name: loginRequest
  262. in: body
  263. required: true
  264. description: Login credentials
  265. schema:
  266. type: object
  267. required:
  268. - username
  269. - password
  270. properties:
  271. username:
  272. description: |
  273. Your username
  274. type: string
  275. password:
  276. description: |
  277. Your password
  278. type: string
  279. format: password
  280. responses:
  281. 200:
  282. description: |-
  283. Successful authentication
  284. schema:
  285. type: object
  286. required:
  287. - id
  288. - token
  289. - tokenExpires
  290. properties:
  291. id:
  292. type: string
  293. description: User ID
  294. token:
  295. type: string
  296. description: |
  297. Authentication token
  298. tokenExpires:
  299. type: string
  300. format: date-time
  301. description: |
  302. Token expiration date
  303. 400:
  304. description: |
  305. Error in authentication
  306. schema:
  307. type: object
  308. properties:
  309. error:
  310. type: string
  311. reason:
  312. type: string
  313. default:
  314. description: |
  315. Error in authentication`);
  316. const methods = {};
  317. for (const ep of entryPoints){ (methods[ep.path] = methods[ep.path] || []).push(ep); }
  318. const sorted = Object.keys(methods).sort();
  319. for (const pth of sorted){
  320. console.log(` ${methods[pth][0].url}:`);
  321. for (const ep of methods[pth]){
  322. const parameters = pth.split('/').filter(t => t.startsWith(':')).map(t => t.endsWith('Id') ? t.slice(1,-2) : t.slice(1));
  323. console.log(` ${ep.method_name}:`);
  324. console.log(` operationId: ${ep.operationId()}`);
  325. const sum = ep.summary(); if (sum) console.log(` summary: ${sum}`);
  326. const desc = ep.description(); if (desc){ console.log(` description: |`); desc.split('\n').forEach(l => console.log(` ${l.trim() ? l : ''}`)); }
  327. const tags = ep.tags(); if (tags.length){ console.log(' tags:'); tags.forEach(t => console.log(` - ${t}`)); }
  328. if (['post','put'].includes(ep.method_name)) console.log(` consumes:\n - multipart/form-data\n - application/json`);
  329. if (parameters.length || ['post','put'].includes(ep.method_name)) console.log(' parameters:');
  330. if (['post','put'].includes(ep.method_name)){
  331. for (const f of ep.body_params){
  332. console.log(` - name: ${f}\n in: formData`);
  333. const [ptype, optional, pdesc] = ep.docParam(f);
  334. if (pdesc) console.log(` description: |\n ${pdesc}`); else console.log(` description: the ${f} value`);
  335. console.log(` type: ${ptype || 'string'}`);
  336. console.log(` ${optional ? 'required: false' : 'required: true'}`);
  337. }
  338. }
  339. for (const p of parameters){
  340. console.log(` - name: ${p}\n in: path`);
  341. const [ptype, optional, pdesc] = ep.docParam(p);
  342. if (pdesc) console.log(` description: |\n ${pdesc}`); else console.log(` description: the ${p} value`);
  343. console.log(` type: ${ptype || 'string'}`);
  344. console.log(` ${optional ? 'required: false' : 'required: true'}`);
  345. }
  346. console.log(` produces:\n - application/json\n security:\n - UserSecurity: []\n responses:\n '200':\n description: |-\n 200 response`);
  347. const ret = ep.returns();
  348. if (ret){ console.log(' schema:'); printOpenapiReturn(ret, 12); }
  349. }
  350. }
  351. console.log('definitions:');
  352. for (const schema of Object.values(schemas)){
  353. if (!schema.used || !schema.fields) continue;
  354. console.log(` ${schema.name}:`);
  355. console.log(' type: object');
  356. if (schema.doc) console.log(` description: ${schema.doc}`);
  357. console.log(' properties:');
  358. const props = schema.fields.filter(f => !f.name.includes('.'));
  359. const req = [];
  360. for (const prop of props){
  361. const name = prop.name; console.log(` ${name}:`);
  362. if (prop.doc){ console.log(' description: |'); prop.doc.forEach(l => console.log(` ${l.trim() ? l : ''}`)); }
  363. let ptype = prop.type; if (ptype === 'enum' || ptype === 'date') ptype = 'string';
  364. if (ptype !== 'object') console.log(` type: ${ptype}`);
  365. if (prop.type === 'array'){
  366. console.log(' items:');
  367. for (const el of prop.elements){ if (el === 'object') console.log(` $ref: "#/definitions/${schema.name + name.charAt(0).toUpperCase() + name.slice(1)}"`); else console.log(` type: ${el}`); }
  368. } else if (prop.type === 'object'){
  369. if (prop.blackbox) console.log(' type: object');
  370. else console.log(` $ref: "#/definitions/${schema.name + name.charAt(0).toUpperCase() + name.slice(1)}"`);
  371. }
  372. if (!prop.name.includes('.') && !prop.required) console.log(' x-nullable: true');
  373. if (prop.required) req.push(name);
  374. }
  375. if (req.length){ console.log(' required:'); req.forEach(f => console.log(` - ${f}`)); }
  376. }
  377. }
  378. function main(){
  379. const argv = process.argv.slice(2);
  380. let version = 'git-master';
  381. let dir = path.resolve(__dirname, '../models');
  382. for (let i = 0; i < argv.length; i++){
  383. if (argv[i] === '--release' && argv[i+1]) { version = argv[i+1]; i++; continue; }
  384. if (!argv[i].startsWith('--')) { dir = path.resolve(argv[i]); }
  385. }
  386. const { schemas, entryPoints } = parseSchemas(dir);
  387. generateOpenapi(schemas, entryPoints, version);
  388. }
  389. if (require.main === module) main();