Lauri Ojansivu 1 week ago
parent
commit
37c5436087
1 changed files with 0 additions and 406 deletions
  1. 0 406
      openapi/generate_openapi.js

+ 0 - 406
openapi/generate_openapi.js

@@ -1,406 +0,0 @@
-#!/usr/bin/env node
-/*
-  Node.js port of openapi/generate_openapi.py (minimal, Node 14 compatible).
-  Parses models to produce an OpenAPI 2.0 YAML on stdout.
-*/
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const esprima = require('esprima');
-
-function cleanupJsdocs(jsdoc) {
-  const lines = jsdoc.value.split('\n')
-    .map(s => s.replace(/^\s*/, ''))
-    .map(s => s.replace(/^\*/, ''));
-  while (lines.length && !lines[0].trim()) lines.shift();
-  while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
-  return lines;
-}
-
-function loadReturnTypeJsdocJson(data) {
-  let s = data;
-  const repl = [
-    [/\n/g, ' '],
-    [/([\{\s,])(\w+)(:)/g, '$1"$2"$3'],
-    [/(:)\s*([^:\},\]]+)\s*([\},\]])/g, '$1"$2"$3'],
-    [/([\[])\s*([^\{].+)\s*(\])/g, '$1"$2"$3'],
-    [/^\s*([^\[{].+)\s*/, '"$1"']
-  ];
-  for (const [r, rep] of repl) s = s.replace(r, rep);
-  try { return JSON.parse(s); } catch { return data; }
-}
-
-class Context {
-  constructor(filePath) {
-    this.path = filePath;
-    this._txt = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
-    const data = this._txt.join('\n');
-    this.program = esprima.parseModule(data, { comment: true, loc: true, range: true });
-  }
-  textAt(begin, end) { return this._txt.slice(begin - 1, end).join('\n'); }
-}
-
-function parseFile(filePath) {
-  try { return new Context(filePath); } catch { return undefined; }
-}
-
-function getReqBodyElems(node, acc) {
-  if (!node) return '';
-  switch (node.type) {
-    case 'FunctionExpression':
-    case 'ArrowFunctionExpression': return getReqBodyElems(node.body, acc);
-    case 'BlockStatement': node.body.forEach(s => getReqBodyElems(s, acc)); return '';
-    case 'TryStatement': return getReqBodyElems(node.block, acc);
-    case 'ExpressionStatement': return getReqBodyElems(node.expression, acc);
-    case 'MemberExpression': {
-      const left = getReqBodyElems(node.object, acc);
-      const right = node.property && node.property.name;
-      if (left === 'req.body' && right && !acc.includes(right)) acc.push(right);
-      return `${left}.${right}`;
-    }
-    case 'VariableDeclaration': node.declarations.forEach(s => getReqBodyElems(s, acc)); return '';
-    case 'VariableDeclarator': return getReqBodyElems(node.init, acc);
-    case 'Property': return getReqBodyElems(node.value, acc);
-    case 'ObjectExpression': node.properties.forEach(s => getReqBodyElems(s, acc)); return '';
-    case 'CallExpression': node.arguments.forEach(s => getReqBodyElems(s, acc)); return '';
-    case 'ArrayExpression': node.elements.forEach(s => getReqBodyElems(s, acc)); return '';
-    case 'IfStatement': getReqBodyElems(node.test, acc); if (node.consequent) getReqBodyElems(node.consequent, acc); if (node.alternate) getReqBodyElems(node.alternate, acc); return '';
-    case 'LogicalExpression':
-    case 'BinaryExpression':
-    case 'AssignmentExpression': getReqBodyElems(node.left, acc); getReqBodyElems(node.right, acc); return '';
-    case 'ChainExpression': return getReqBodyElems(node.expression, acc);
-    case 'ReturnStatement':
-    case 'UnaryExpression': if (node.argument) return getReqBodyElems(node.argument, acc); return '';
-    case 'Identifier': return node.name;
-    default: return '';
-  }
-}
-
-class EntryPoint {
-  constructor(schema, [method, pathLit, body]) {
-    this.schema = schema;
-    this.method = method; this._path = pathLit; this.body = body;
-    this._rawDoc = null; this._doc = {}; this._jsdoc = null;
-    this.path = (this._path.value || '').replace(/\/$/, '');
-    this.method_name = (this.method.value || '').toLowerCase();
-    this.body_params = [];
-    if (['post','put'].includes(this.method_name)) getReqBodyElems(this.body, this.body_params);
-    let url = this.path.replace(/:([^\/]*)Id/g, '{$1}').replace(/:([^\/]*)/g, '{$1}');
-    this.url = url;
-    const tokens = url.split('/');
-    const reduced = [];
-    for (let i = 0; i < tokens.length; i++) {
-      const t = tokens[i];
-      if (t === 'api') continue;
-      if (i < tokens.length - 1 && tokens[i+1].startsWith('{')) continue;
-      reduced.push(t.replace(/[{}]/g, ''));
-    }
-    this.reduced_function_name = reduced.join('_');
-    schema.used = true;
-  }
-  set doc(doc) { this._rawDoc = doc; this._jsdoc = cleanupJsdocs(doc); this._doc = this.parseDoc(); }
-  get doc() { return this._doc; }
-  parseDoc() {
-    if (!this._jsdoc) return {};
-    const result = {};
-    let currentTag = 'description';
-    let current = '';
-    const store = (tag, data) => {
-      if (data == null) return; const s = (data + '').replace(/\s+$/,''); if (!s) return;
-      if (tag === 'param') {
-        result.params = result.params || {};
-        let nameDesc = s.trim();
-        let paramType = null;
-        let name = nameDesc; let desc = '';
-        const mType = nameDesc.match(/^\{([^}]+)\}\s*(.*)$/);
-        if (mType) { paramType = mType[1]; nameDesc = mType[2]; }
-        const sp = nameDesc.split(/\s+/, 2); name = sp[0]; desc = sp[1] || '';
-        const optional = /^\[.*\]$/.test(name); if (optional) name = name.slice(1,-1);
-        result.params[name] = [paramType, optional, desc];
-        if (name.endsWith('Id')) { const base = name.slice(0,-2); if (!result.params[base]) result.params[base] = [paramType, optional, desc]; }
-        return;
-      }
-      if (tag === 'tag') { (result.tag = result.tag || []).push(s); return; }
-      if (tag === 'return_type') { result[tag] = loadReturnTypeJsdocJson(s); return; }
-      result[tag] = s;
-    };
-    for (const lineRaw of this._jsdoc) {
-      let line = lineRaw;
-      if (/^@/.test(line.trim())) {
-        const parts = line.trim().split(/\s+/, 2);
-        const tag = parts[0]; const rest = parts[1] || '';
-        if (['@operation','@summary','@description','@param','@return_type','@tag'].includes(tag)) {
-          store(currentTag, current);
-          currentTag = tag.slice(1); current = ''; line = rest;
-        }
-      }
-      current += line + '\n';
-    }
-    store(currentTag, current);
-    return result;
-  }
-  get summary() { return this._doc.summary ? this._doc.summary.replace(/\n/g,' ') : null; }
-  docParam(name) { const p = (this._doc.params||{})[name]; return p ? p : [null,null,null]; }
-  operationId() { return this._doc.operation || `${this.method_name}_${this.reduced_function_name}`; }
-  description() { return this._doc.description || null; }
-  returns() { return this._doc.return_type || null; }
-  tags() { const tags = []; if (this.schema.fields) tags.push(this.schema.name); if (this._doc.tag) tags.push(...this._doc.tag); return tags; }
-}
-
-class SchemaProperty {
-  constructor(statement, schema, context) {
-    this.schema = schema;
-    this.statement = statement;
-    this.name = (statement.key.name || statement.key.value);
-    this.type = 'object'; this.blackbox = false; this.required = true; this.elements = [];
-    (statement.value.properties || []).forEach(p => {
-      try {
-        const key = p.key && (p.key.name || p.key.value);
-        if (key === 'type') {
-          if (p.value.type === 'Identifier') this.type = (p.value.name || '').toLowerCase();
-          else if (p.value.type === 'ArrayExpression') { this.type = 'array'; this.elements = (p.value.elements||[]).map(e => (e.name||'object').toLowerCase()); }
-        } else if (key === 'blackbox') { this.blackbox = true; }
-        else if (key === 'optional' && p.value && p.value.value) { this.required = false; }
-      } catch(e) { /* ignore minor parse errors */ }
-    });
-    this._doc = null; this._raw_doc = null;
-  }
-  set doc(jsdoc){ this._raw_doc = jsdoc; this._doc = cleanupJsdocs(jsdoc); }
-  get doc(){ return this._doc; }
-}
-
-class Schemas {
-  constructor(context, statement, jsdocs, name) {
-    this.name = name || null; this._data = statement; this.fields = null; this.used = false;
-    if (statement) {
-      if (!this.name) this.name = statement.expression.callee.object.name;
-      const content = statement.expression.arguments[0].arguments[0];
-      this.fields = (content.properties || []).map(p => new SchemaProperty(p, this, context));
-    }
-    this._doc = null; this._raw_doc = null;
-    if (jsdocs) this.processJsdocs(jsdocs);
-  }
-  get doc(){ return this._doc ? this._doc.join(' ') : null; }
-  set doc(jsdoc){ this._raw_doc = jsdoc; this._doc = cleanupJsdocs(jsdoc); }
-  processJsdocs(jsdocs){
-    if (!this._data) return;
-    const start = this._data.loc.start.line, end = this._data.loc.end.line;
-    for (const doc of jsdocs){ if (doc.loc.end.line + 1 === start) { this.doc = doc; break; } }
-    const inRange = jsdocs.filter(doc => doc.loc.start.line >= start && doc.loc.end.line <= end);
-    for (const f of (this.fields||[]))
-      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; } }
-  }
-}
-
-function parseSchemas(schemasDir){
-  const schemas = {}; const entryPoints = [];
-  const walk = dir => {
-    const items = fs.readdirSync(dir, { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name));
-    for (const it of items){
-      const p = path.join(dir, it.name);
-      if (it.isDirectory()) walk(p);
-      else if (it.isFile()){
-        const context = parseFile(p); if (!context) continue; const program = context.program;
-        let currentSchema = null;
-        const jsdocs = (program.comments||[]).filter(c => c.type === 'Block' && c.value.startsWith('*\n'));
-        for (const statement of program.body){
-          try {
-            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'){
-              const schema = new Schemas(context, statement, jsdocs);
-              currentSchema = schema.name; schemas[currentSchema] = schema;
-            } 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'){
-              const conseq = statement.consequent && statement.consequent.body || [];
-              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);
-              if (data.length){
-                if (!currentSchema){ currentSchema = path.basename(p); schemas[currentSchema] = new Schemas(context, null, null, currentSchema); }
-                const eps = data.map(d => new EntryPoint(schemas[currentSchema], d)); entryPoints.push(...eps);
-                let endOfPrev = -1;
-                for (const ep of eps){
-                  const op = ep.method; const prior = jsdocs.filter(j => j.loc.end.line + 1 <= op.loc.start.line && j.loc.start.line > endOfPrev);
-                  if (prior.length) ep.doc = prior[prior.length - 1];
-                  endOfPrev = op.loc.end.line;
-                }
-              }
-            }
-          } catch(e){ /* ignore parse hiccups per file */ }
-        }
-      }
-    }
-  };
-  walk(schemasDir);
-  return { schemas, entryPoints };
-}
-
-function printOpenapiReturn(obj, indent){
-  const pad = ' '.repeat(indent);
-  if (Array.isArray(obj)){
-    console.log(`${pad}type: array`); console.log(`${pad}items:`); printOpenapiReturn(obj[0], indent+2); return;
-  }
-  if (obj && typeof obj === 'object'){
-    console.log(`${pad}type: object`); console.log(`${pad}properties:`);
-    for (const k of Object.keys(obj)){ console.log(`${pad}  ${k}:`); printOpenapiReturn(obj[k], indent+4); }
-    return;
-  }
-  if (typeof obj === 'string') console.log(`${pad}type: ${obj}`);
-}
-
-function generateOpenapi(schemas, entryPoints, version){
-  console.log(`swagger: '2.0'
-info:
-  title: Wekan REST API
-  version: ${version}
-  description: |
-    The REST API allows you to control and extend Wekan with ease.
-schemes:
-  - http
-securityDefinitions:
-  UserSecurity:
-    type: apiKey
-    in: header
-    name: Authorization
-paths:
-  /users/login:
-    post:
-      operationId: login
-      summary: Login with REST API
-      consumes:
-        - application/json
-        - application/x-www-form-urlencoded
-      tags:
-        - Login
-      parameters:
-        - name: loginRequest
-          in: body
-          required: true
-          description: Login credentials
-          schema:
-            type: object
-            required:
-              - username
-              - password
-            properties:
-              username:
-                description: |
-                  Your username
-                type: string
-              password:
-                description: |
-                  Your password
-                type: string
-                format: password
-      responses:
-        200:
-          description: |-
-            Successful authentication
-          schema:
-            type: object
-            required:
-              - id
-              - token
-              - tokenExpires
-            properties:
-              id:
-                type: string
-                description: User ID
-              token:
-                type: string
-                description: |
-                  Authentication token
-              tokenExpires:
-                type: string
-                format: date-time
-                description: |
-                  Token expiration date
-        400:
-          description: |
-            Error in authentication
-          schema:
-            type: object
-            properties:
-              error:
-                type: string
-              reason:
-                type: string
-        default:
-          description: |
-            Error in authentication`);
-
-  const methods = {};
-  for (const ep of entryPoints){ (methods[ep.path] = methods[ep.path] || []).push(ep); }
-  const sorted = Object.keys(methods).sort();
-  for (const pth of sorted){
-    console.log(`  ${methods[pth][0].url}:`);
-    for (const ep of methods[pth]){
-      const parameters = pth.split('/').filter(t => t.startsWith(':')).map(t => t.endsWith('Id') ? t.slice(1,-2) : t.slice(1));
-      console.log(`    ${ep.method_name}:`);
-      console.log(`      operationId: ${ep.operationId()}`);
-      const sum = ep.summary(); if (sum) console.log(`      summary: ${sum}`);
-      const desc = ep.description(); if (desc){ console.log(`      description: |`); desc.split('\n').forEach(l => console.log(`        ${l.trim() ? l : ''}`)); }
-      const tags = ep.tags(); if (tags.length){ console.log('      tags:'); tags.forEach(t => console.log(`        - ${t}`)); }
-      if (['post','put'].includes(ep.method_name)) console.log(`      consumes:\n        - multipart/form-data\n        - application/json`);
-      if (parameters.length || ['post','put'].includes(ep.method_name)) console.log('      parameters:');
-      if (['post','put'].includes(ep.method_name)){
-        for (const f of ep.body_params){
-          console.log(`        - name: ${f}\n          in: formData`);
-          const [ptype, optional, pdesc] = ep.docParam(f);
-          if (pdesc) console.log(`          description: |\n            ${pdesc}`); else console.log(`          description: the ${f} value`);
-          console.log(`          type: ${ptype || 'string'}`);
-          console.log(`          ${optional ? 'required: false' : 'required: true'}`);
-        }
-      }
-      for (const p of parameters){
-        console.log(`        - name: ${p}\n          in: path`);
-        const [ptype, optional, pdesc] = ep.docParam(p);
-        if (pdesc) console.log(`          description: |\n            ${pdesc}`); else console.log(`          description: the ${p} value`);
-        console.log(`          type: ${ptype || 'string'}`);
-        console.log(`          ${optional ? 'required: false' : 'required: true'}`);
-      }
-      console.log(`      produces:\n        - application/json\n      security:\n          - UserSecurity: []\n      responses:\n        '200':\n          description: |-\n            200 response`);
-      const ret = ep.returns();
-      if (ret){ console.log('          schema:'); printOpenapiReturn(ret, 12); }
-    }
-  }
-  console.log('definitions:');
-  for (const schema of Object.values(schemas)){
-    if (!schema.used || !schema.fields) continue;
-    console.log(`  ${schema.name}:`);
-    console.log('    type: object');
-    if (schema.doc) console.log(`    description: ${schema.doc}`);
-    console.log('    properties:');
-    const props = schema.fields.filter(f => !f.name.includes('.'));
-    const req = [];
-    for (const prop of props){
-      const name = prop.name; console.log(`      ${name}:`);
-      if (prop.doc){ console.log('        description: |'); prop.doc.forEach(l => console.log(`          ${l.trim() ? l : ''}`)); }
-      let ptype = prop.type; if (ptype === 'enum' || ptype === 'date') ptype = 'string';
-      if (ptype !== 'object') console.log(`        type: ${ptype}`);
-      if (prop.type === 'array'){
-        console.log('        items:');
-        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}`); }
-      } else if (prop.type === 'object'){
-        if (prop.blackbox) console.log('        type: object');
-        else console.log(`        $ref: "#/definitions/${schema.name + name.charAt(0).toUpperCase() + name.slice(1)}"`);
-      }
-      if (!prop.name.includes('.') && !prop.required) console.log('        x-nullable: true');
-      if (prop.required) req.push(name);
-    }
-    if (req.length){ console.log('    required:'); req.forEach(f => console.log(`      - ${f}`)); }
-  }
-}
-
-function main(){
-  const argv = process.argv.slice(2);
-  let version = 'git-master';
-  let dir = path.resolve(__dirname, '../models');
-  for (let i = 0; i < argv.length; i++){
-    if (argv[i] === '--release' && argv[i+1]) { version = argv[i+1]; i++; continue; }
-    if (!argv[i].startsWith('--')) { dir = path.resolve(argv[i]); }
-  }
-  const { schemas, entryPoints } = parseSchemas(dir);
-  generateOpenapi(schemas, entryPoints, version);
-}
-
-if (require.main === module) main();
-
-