generate_openapi.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. #!/bin/env python3
  2. import argparse
  3. import esprima
  4. import json
  5. import logging
  6. import os
  7. import re
  8. import sys
  9. import traceback
  10. logger = logging.getLogger(__name__)
  11. err_context = 3
  12. def get_req_body_elems(obj, elems):
  13. if obj.type in ['FunctionExpression', 'ArrowFunctionExpression']:
  14. get_req_body_elems(obj.body, elems)
  15. elif obj.type == 'BlockStatement':
  16. for s in obj.body:
  17. get_req_body_elems(s, elems)
  18. elif obj.type == 'TryStatement':
  19. get_req_body_elems(obj.block, elems)
  20. elif obj.type == 'ExpressionStatement':
  21. get_req_body_elems(obj.expression, elems)
  22. elif obj.type == 'MemberExpression':
  23. left = get_req_body_elems(obj.object, elems)
  24. right = obj.property.name
  25. if left == 'req.body' and right not in elems:
  26. elems.append(right)
  27. return '{}.{}'.format(left, right)
  28. elif obj.type == 'VariableDeclaration':
  29. for s in obj.declarations:
  30. get_req_body_elems(s, elems)
  31. elif obj.type == 'VariableDeclarator':
  32. if obj.id.type == 'ObjectPattern':
  33. # get_req_body_elems() can't be called directly here:
  34. # const {isAdmin, isNoComments, isCommentOnly} = req.body;
  35. right = get_req_body_elems(obj.init, elems)
  36. if right == 'req.body':
  37. for p in obj.id.properties:
  38. name = p.key.name
  39. if name not in elems:
  40. elems.append(name)
  41. else:
  42. get_req_body_elems(obj.init, elems)
  43. elif obj.type == 'Property':
  44. get_req_body_elems(obj.value, elems)
  45. elif obj.type == 'ObjectExpression':
  46. for s in obj.properties:
  47. get_req_body_elems(s, elems)
  48. elif obj.type == 'CallExpression':
  49. for s in obj.arguments:
  50. get_req_body_elems(s, elems)
  51. elif obj.type == 'ArrayExpression':
  52. for s in obj.elements:
  53. get_req_body_elems(s, elems)
  54. elif obj.type == 'IfStatement':
  55. get_req_body_elems(obj.test, elems)
  56. if obj.consequent is not None:
  57. get_req_body_elems(obj.consequent, elems)
  58. if obj.alternate is not None:
  59. get_req_body_elems(obj.alternate, elems)
  60. elif obj.type in ('LogicalExpression', 'BinaryExpression', 'AssignmentExpression'):
  61. get_req_body_elems(obj.left, elems)
  62. get_req_body_elems(obj.right, elems)
  63. elif obj.type in ('ReturnStatement', 'UnaryExpression'):
  64. get_req_body_elems(obj.argument, elems)
  65. elif obj.type == 'Literal':
  66. pass
  67. elif obj.type == 'Identifier':
  68. return obj.name
  69. elif obj.type == 'FunctionDeclaration':
  70. pass
  71. else:
  72. print(obj)
  73. return ''
  74. def cleanup_jsdocs(jsdoc):
  75. # remove leading spaces before the first '*'
  76. doc = [s.lstrip() for s in jsdoc.value.split('\n')]
  77. # remove leading stars
  78. doc = [s.lstrip('*') for s in doc]
  79. # remove leading empty lines
  80. while len(doc) and not doc[0].strip():
  81. doc.pop(0)
  82. # remove terminating empty lines
  83. while len(doc) and not doc[-1].strip():
  84. doc.pop(-1)
  85. return doc
  86. class JS2jsonDecoder(json.JSONDecoder):
  87. def decode(self, s):
  88. result = super().decode(s) # result = super(Decoder, self).decode(s) for Python 2.x
  89. return self._decode(result)
  90. def _decode(self, o):
  91. if isinstance(o, str) or isinstance(o, unicode):
  92. try:
  93. return int(o)
  94. except ValueError:
  95. return o
  96. elif isinstance(o, dict):
  97. return {k: self._decode(v) for k, v in o.items()}
  98. elif isinstance(o, list):
  99. return [self._decode(v) for v in o]
  100. else:
  101. return o
  102. def load_return_type_jsdoc_json(data):
  103. regex_replace = [(r'\n', r' '), # replace new lines by spaces
  104. (r'([\{\s,])(\w+)(:)', r'\1"\2"\3'), # insert double quotes in keys
  105. (r'(:)\s*([^:\},\]]+)\s*([\},\]])', r'\1"\2"\3'), # insert double quotes in values
  106. (r'(\[)\s*([^{].+)\s*(\])', r'\1"\2"\3'), # insert double quotes in array items
  107. (r'^\s*([^\[{].+)\s*', r'"\1"')] # insert double quotes in single item
  108. for r, s in regex_replace:
  109. data = re.sub(r, s, data)
  110. return json.loads(data)
  111. class EntryPoint(object):
  112. def __init__(self, schema, statements):
  113. self.schema = schema
  114. self.method, self._path, self.body = statements
  115. self._jsdoc = None
  116. self._doc = {}
  117. self._raw_doc = None
  118. self.path = self.compute_path()
  119. self.method_name = self.method.value.lower()
  120. self.body_params = []
  121. if self.method_name in ('post', 'put'):
  122. get_req_body_elems(self.body, self.body_params)
  123. # replace the :parameter in path by {parameter}
  124. self.url = re.sub(r':([^/]*)Id', r'{\1}', self.path)
  125. self.url = re.sub(r':([^/]*)', r'{\1}', self.url)
  126. # reduce the api name
  127. # get_boards_board_cards() should be get_board_cards()
  128. tokens = self.url.split('/')
  129. reduced_function_name = []
  130. for i, token in enumerate(tokens):
  131. if token in ('api'):
  132. continue
  133. if (i < len(tokens) - 1 and # not the last item
  134. tokens[i + 1].startswith('{')): # and the next token is a parameter
  135. continue
  136. reduced_function_name.append(token.strip('{}'))
  137. self.reduced_function_name = '_'.join(reduced_function_name)
  138. # mark the schema as used
  139. schema.used = True
  140. def compute_path(self):
  141. return self._path.value.rstrip('/')
  142. def log(self, message, level):
  143. if self._raw_doc is None:
  144. logger.log(level, 'in {},'.format(self.schema.name))
  145. logger.log(level, message)
  146. return
  147. logger.log(level, 'in {}, lines {}-{}'.format(self.schema.name,
  148. self._raw_doc.loc.start.line,
  149. self._raw_doc.loc.end.line))
  150. logger.log(level, self._raw_doc.value)
  151. logger.log(level, message)
  152. def error(self, message):
  153. return self.log(message, logging.ERROR)
  154. def warn(self, message):
  155. return self.log(message, logging.WARNING)
  156. def info(self, message):
  157. return self.log(message, logging.INFO)
  158. @property
  159. def doc(self):
  160. return self._doc
  161. @doc.setter
  162. def doc(self, doc):
  163. '''Parse the JSDoc attached to an entry point.
  164. `jsdoc` will not get these right as they are not attached to a method.
  165. So instead, we do our custom parsing here (yes, subject to errors).
  166. The expected format is the following (empty lines between entries
  167. are ignored):
  168. /**
  169. * @operation name_of_entry_point
  170. * @tag: a_tag_to_add
  171. * @tag: an_other_tag_to_add
  172. * @summary A nice summary, better in one line.
  173. *
  174. * @description This is a quite long description.
  175. * We can use *mardown* as the final rendering is done
  176. * by slate.
  177. *
  178. * indentation doesn't matter.
  179. *
  180. * @param param_0 description of param 0
  181. * @param {string} param_1 we can also put the type of the parameter
  182. * before its name, like in JSDoc
  183. * @param {boolean} [param_2] we can also tell if the parameter is
  184. * optional by adding square brackets around its name
  185. *
  186. * @return Documents a return value
  187. */
  188. Notes:
  189. - name_of_entry_point will be referenced in the ToC of the generated
  190. document. This is also the operationId used in the resulting openapi
  191. file. It needs to be uniq in the namesapce (the current schema.js
  192. file)
  193. - tags are appended to the current Schema attached to the file
  194. '''
  195. self._raw_doc = doc
  196. self._jsdoc = cleanup_jsdocs(doc)
  197. def store_tag(tag, data):
  198. # check that there is something to store first
  199. if not data.strip():
  200. return
  201. # remove terminating whitespaces and empty lines
  202. data = data.rstrip()
  203. # parameters are handled specially
  204. if tag == 'param':
  205. if 'params' not in self._doc:
  206. self._doc['params'] = {}
  207. params = self._doc['params']
  208. param_type = None
  209. try:
  210. name, desc = data.split(maxsplit=1)
  211. except ValueError:
  212. desc = ''
  213. if name.startswith('{'):
  214. param_type = name.strip('{}')
  215. if param_type == 'Object':
  216. # hope for the best
  217. param_type = 'object'
  218. elif param_type not in ['string', 'number', 'boolean', 'integer', 'array', 'file']:
  219. self.warn('unknown type {}\n allowed values: string, number, boolean, integer, array, file'.format(param_type))
  220. try:
  221. name, desc = desc.split(maxsplit=1)
  222. except ValueError:
  223. desc = ''
  224. optional = name.startswith('[') and name.endswith(']')
  225. if optional:
  226. name = name[1:-1]
  227. # we should not have 2 identical parameter names
  228. if tag in params:
  229. self.warn('overwriting parameter {}'.format(name))
  230. params[name] = (param_type, optional, desc)
  231. if name.endswith('Id'):
  232. # we strip out the 'Id' from the form parameters, we need
  233. # to keep the actual description around
  234. name = name[:-2]
  235. if name not in params:
  236. params[name] = (param_type, optional, desc)
  237. return
  238. # 'tag' can be set several times
  239. if tag == 'tag':
  240. if tag not in self._doc:
  241. self._doc[tag] = []
  242. self._doc[tag].append(data)
  243. return
  244. # 'return' tag is json
  245. if tag == 'return_type':
  246. try:
  247. data = load_return_type_jsdoc_json(data)
  248. except json.decoder.JSONDecodeError:
  249. pass
  250. # we should not have 2 identical tags but @param or @tag
  251. if tag in self._doc:
  252. self.warn('overwriting tag {}'.format(tag))
  253. self._doc[tag] = data
  254. # reset the current doc fields
  255. self._doc = {}
  256. # first item is supposed to be the description
  257. current_tag = 'description'
  258. current_data = ''
  259. for line in self._jsdoc:
  260. if line.lstrip().startswith('@'):
  261. tag, data = line.lstrip().split(maxsplit=1)
  262. if tag in ['@operation', '@summary', '@description', '@param', '@return_type', '@tag']:
  263. # store the current data
  264. store_tag(current_tag, current_data)
  265. current_tag = tag.lstrip('@')
  266. current_data = ''
  267. line = data
  268. else:
  269. self.info('Unknown tag {}, ignoring'.format(tag))
  270. current_data += line + '\n'
  271. store_tag(current_tag, current_data)
  272. @property
  273. def summary(self):
  274. if 'summary' in self._doc:
  275. # new lines are not allowed
  276. return self._doc['summary'].replace('\n', ' ')
  277. return None
  278. def doc_param(self, name):
  279. if 'params' in self._doc and name in self._doc['params']:
  280. return self._doc['params'][name]
  281. return None, None, None
  282. def print_openapi_param(self, name, indent):
  283. ptype, poptional, pdesc = self.doc_param(name)
  284. if pdesc is not None:
  285. print('{}description: |'.format(' ' * indent))
  286. print('{}{}'.format(' ' * (indent + 2), pdesc))
  287. else:
  288. print('{}description: the {} value'.format(' ' * indent, name))
  289. if ptype is not None:
  290. print('{}type: {}'.format(' ' * indent, ptype))
  291. else:
  292. print('{}type: string'.format(' ' * indent))
  293. if poptional:
  294. print('{}required: false'.format(' ' * indent))
  295. else:
  296. print('{}required: true'.format(' ' * indent))
  297. @property
  298. def operationId(self):
  299. if 'operation' in self._doc:
  300. return self._doc['operation']
  301. return '{}_{}'.format(self.method_name, self.reduced_function_name)
  302. @property
  303. def description(self):
  304. if 'description' in self._doc:
  305. return self._doc['description']
  306. return None
  307. @property
  308. def returns(self):
  309. if 'return_type' in self._doc:
  310. return self._doc['return_type']
  311. return None
  312. @property
  313. def tags(self):
  314. tags = []
  315. if self.schema.fields is not None:
  316. tags.append(self.schema.name)
  317. if 'tag' in self._doc:
  318. tags.extend(self._doc['tag'])
  319. return tags
  320. def print_openapi_return(self, obj, indent):
  321. if isinstance(obj, dict):
  322. print('{}type: object'.format(' ' * indent))
  323. print('{}properties:'.format(' ' * indent))
  324. for k, v in obj.items():
  325. print('{}{}:'.format(' ' * (indent + 2), k))
  326. self.print_openapi_return(v, indent + 4)
  327. elif isinstance(obj, list):
  328. if len(obj) > 1:
  329. self.error('Error while parsing @return tag, an array should have only one type')
  330. print('{}type: array'.format(' ' * indent))
  331. print('{}items:'.format(' ' * indent))
  332. self.print_openapi_return(obj[0], indent + 2)
  333. elif isinstance(obj, str) or isinstance(obj, unicode):
  334. rtype = 'type: ' + obj
  335. if obj == self.schema.name:
  336. rtype = '$ref: "#/definitions/{}"'.format(obj)
  337. print('{}{}'.format(' ' * indent, rtype))
  338. def print_openapi(self):
  339. parameters = [token[1:-2] if token.endswith('Id') else token[1:]
  340. for token in self.path.split('/')
  341. if token.startswith(':')]
  342. print(' {}:'.format(self.method_name))
  343. print(' operationId: {}'.format(self.operationId))
  344. if self.summary is not None:
  345. print(' summary: {}'.format(self.summary))
  346. if self.description is not None:
  347. print(' description: |')
  348. for line in self.description.split('\n'):
  349. if line.strip():
  350. print(' {}'.format(line))
  351. else:
  352. print('')
  353. if len(self.tags) > 0:
  354. print(' tags:')
  355. for tag in self.tags:
  356. print(' - {}'.format(tag))
  357. # export the parameters
  358. if self.method_name in ('post', 'put'):
  359. print(''' consumes:
  360. - multipart/form-data
  361. - application/json''')
  362. if len(parameters) > 0 or self.method_name in ('post', 'put'):
  363. print(' parameters:')
  364. if self.method_name in ('post', 'put'):
  365. for f in self.body_params:
  366. print(''' - name: {}
  367. in: formData'''.format(f))
  368. self.print_openapi_param(f, 10)
  369. for p in parameters:
  370. if p in self.body_params:
  371. self.error(' '.join((p, self.path, self.method_name)))
  372. print(''' - name: {}
  373. in: path'''.format(p))
  374. self.print_openapi_param(p, 10)
  375. print(''' produces:
  376. - application/json
  377. security:
  378. - UserSecurity: []
  379. responses:
  380. '200':
  381. description: |-
  382. 200 response''')
  383. if self.returns is not None:
  384. print(' schema:')
  385. self.print_openapi_return(self.returns, 12)
  386. class SchemaProperty(object):
  387. def __init__(self, statement, schema, context):
  388. self.schema = schema
  389. self.statement = statement
  390. self.name = statement.key.name or statement.key.value
  391. self.type = 'object'
  392. self.blackbox = False
  393. self.required = True
  394. imports = {}
  395. for p in statement.value.properties:
  396. try:
  397. if p.key.name == 'type':
  398. if p.value.type == 'Identifier':
  399. self.type = p.value.name.lower()
  400. elif p.value.type == 'ArrayExpression':
  401. self.type = 'array'
  402. self.elements = [e.name.lower() for e in p.value.elements]
  403. elif p.key.name == 'allowedValues':
  404. self.type = 'enum'
  405. self.enum = []
  406. def parse_enum(value, enum):
  407. if value.type == 'ArrayExpression':
  408. for e in value.elements:
  409. parse_enum(e, enum)
  410. elif value.type == 'Literal':
  411. enum.append(value.value.lower())
  412. return
  413. elif value.type == 'Identifier':
  414. # tree wide lookout for the identifier
  415. def find_variable(elem, match):
  416. if isinstance(elem, list):
  417. for value in elem:
  418. ret = find_variable(value, match)
  419. if ret is not None:
  420. return ret
  421. try:
  422. items = elem.items()
  423. except AttributeError:
  424. return None
  425. except TypeError:
  426. return None
  427. if (elem.type == 'VariableDeclarator' and
  428. elem.id.name == match):
  429. return elem
  430. elif (elem.type == 'ImportSpecifier' and
  431. elem.local.name == match):
  432. # we have to treat that case in the caller, because we lack
  433. # information of the source of the import at that point
  434. return elem
  435. elif (elem.type == 'ExportNamedDeclaration' and
  436. elem.declaration.type == 'VariableDeclaration'):
  437. ret = find_variable(elem.declaration.declarations, match)
  438. if ret is not None:
  439. return ret
  440. for type, value in items:
  441. ret = find_variable(value, match)
  442. if ret is not None:
  443. if ret.type == 'ImportSpecifier':
  444. # first open and read the import source, if
  445. # we haven't already done so
  446. path = elem.source.value
  447. if elem.source.value.startswith('/'):
  448. script_dir = os.path.dirname(os.path.realpath(__file__))
  449. path = os.path.abspath(os.path.join('{}/..'.format(script_dir), elem.source.value.lstrip('/')))
  450. else:
  451. path = os.path.abspath(os.path.join(os.path.dirname(context.path), elem.source.value))
  452. path += '.js'
  453. if path not in imports:
  454. imported_context = parse_file(path)
  455. imports[path] = imported_context
  456. imported_context = imports[path]
  457. # and then re-run the find in the imported file
  458. return find_variable(imported_context.program.body, match)
  459. return ret
  460. return None
  461. elem = find_variable(context.program.body, value.name)
  462. if elem is None:
  463. raise TypeError('can not find "{}"'.format(value.name))
  464. return parse_enum(elem.init, enum)
  465. parse_enum(p.value, self.enum)
  466. elif p.key.name == 'blackbox':
  467. self.blackbox = True
  468. elif p.key.name == 'optional' and p.value.value:
  469. self.required = False
  470. except Exception:
  471. input = ''
  472. for line in range(p.loc.start.line - err_context, p.loc.end.line + 1 + err_context):
  473. if line < p.loc.start.line or line > p.loc.end.line:
  474. input += '. '
  475. else:
  476. input += '>>'
  477. input += context.text_at(line, line)
  478. input = ''.join(input)
  479. logger.error('{}:{}-{} can not parse {}:\n{}'.format(context.path,
  480. p.loc.start.line,
  481. p.loc.end.line,
  482. p.type,
  483. input))
  484. logger.error('esprima tree:\n{}'.format(p))
  485. logger.error(traceback.format_exc())
  486. sys.exit(1)
  487. self._doc = None
  488. self._raw_doc = None
  489. @property
  490. def doc(self):
  491. return self._doc
  492. @doc.setter
  493. def doc(self, jsdoc):
  494. self._raw_doc = jsdoc
  495. self._doc = cleanup_jsdocs(jsdoc)
  496. def process_jsdocs(self, jsdocs):
  497. start = self.statement.key.loc.start.line
  498. for index, doc in enumerate(jsdocs):
  499. if start + 1 == doc.loc.start.line:
  500. self.doc = doc
  501. jsdocs.pop(index)
  502. return
  503. def __repr__(self):
  504. return 'SchemaProperty({}{}, {})'.format(self.name,
  505. '*' if self.required else '',
  506. self.doc)
  507. def print_openapi(self, indent, current_schema, required_properties):
  508. schema_name = self.schema.name
  509. name = self.name
  510. # deal with subschemas
  511. if '.' in name:
  512. subschema = name.split('.')[0]
  513. subschema = subschema.capitalize()
  514. if name.endswith('$'):
  515. # reference in reference
  516. subschema = ''.join([n.capitalize() for n in self.name.split('.')[:-1]])
  517. subschema = self.schema.name + subschema
  518. if current_schema != subschema:
  519. if required_properties is not None and required_properties:
  520. print(' required:')
  521. for f in required_properties:
  522. print(' - {}'.format(f))
  523. required_properties.clear()
  524. print(''' {}:
  525. type: object'''.format(subschema))
  526. return current_schema
  527. elif '$' in name:
  528. # In the form of 'profile.notifications.$.activity'
  529. subschema = name[:name.index('$') - 1] # 'profile.notifications'
  530. subschema = ''.join([s.capitalize() for s in subschema.split('.')])
  531. schema_name = self.schema.name + subschema
  532. name = name.split('.')[-1]
  533. if current_schema != schema_name:
  534. if required_properties is not None and required_properties:
  535. print(' required:')
  536. for f in required_properties:
  537. print(' - {}'.format(f))
  538. required_properties.clear()
  539. print(''' {}:
  540. type: object
  541. properties:'''.format(schema_name))
  542. if required_properties is not None and self.required:
  543. required_properties.append(name)
  544. print('{}{}:'.format(' ' * indent, name))
  545. if self.doc is not None:
  546. print('{} description: |'.format(' ' * indent))
  547. for line in self.doc:
  548. if line.strip():
  549. print('{} {}'.format(' ' * indent, line))
  550. else:
  551. print('')
  552. ptype = self.type
  553. if ptype in ('enum', 'date'):
  554. ptype = 'string'
  555. if ptype != 'object':
  556. print('{} type: {}'.format(' ' * indent, ptype))
  557. if self.type == 'array':
  558. print('{} items:'.format(' ' * indent))
  559. for elem in self.elements:
  560. if elem == 'object':
  561. print('{} $ref: "#/definitions/{}"'.format(' ' * indent, schema_name + name.capitalize()))
  562. else:
  563. print('{} type: {}'.format(' ' * indent, elem))
  564. if not self.required:
  565. print('{} x-nullable: true'.format(' ' * indent))
  566. elif self.type == 'object':
  567. if self.blackbox:
  568. print('{} type: object'.format(' ' * indent))
  569. else:
  570. print('{} $ref: "#/definitions/{}"'.format(' ' * indent, schema_name + name.capitalize()))
  571. elif self.type == 'enum':
  572. print('{} enum:'.format(' ' * indent))
  573. for enum in self.enum:
  574. print('{} - {}'.format(' ' * indent, enum))
  575. if '.' not in self.name and not self.required:
  576. print('{} x-nullable: true'.format(' ' * indent))
  577. return schema_name
  578. class Schemas(object):
  579. def __init__(self, context, data=None, jsdocs=None, name=None):
  580. self.name = name
  581. self._data = data
  582. self.fields = None
  583. self.used = False
  584. if data is not None:
  585. if self.name is None:
  586. self.name = data.expression.callee.object.name
  587. content = data.expression.arguments[0].arguments[0]
  588. self.fields = [SchemaProperty(p, self, context) for p in content.properties]
  589. self._doc = None
  590. self._raw_doc = None
  591. if jsdocs is not None:
  592. self.process_jsdocs(jsdocs)
  593. @property
  594. def doc(self):
  595. if self._doc is None:
  596. return None
  597. return ' '.join(self._doc)
  598. @doc.setter
  599. def doc(self, jsdoc):
  600. self._raw_doc = jsdoc
  601. self._doc = cleanup_jsdocs(jsdoc)
  602. def process_jsdocs(self, jsdocs):
  603. start = self._data.loc.start.line
  604. end = self._data.loc.end.line
  605. for doc in jsdocs:
  606. if doc.loc.end.line + 1 == start:
  607. self.doc = doc
  608. docs = [doc
  609. for doc in jsdocs
  610. if doc.loc.start.line >= start and doc.loc.end.line <= end]
  611. for field in self.fields:
  612. field.process_jsdocs(docs)
  613. def print_openapi(self):
  614. # empty schemas are skipped
  615. if self.fields is None:
  616. return
  617. print(' {}:'.format(self.name))
  618. print(' type: object')
  619. if self.doc is not None:
  620. print(' description: {}'.format(self.doc))
  621. print(' properties:')
  622. # first print out the object itself
  623. properties = [field for field in self.fields if '.' not in field.name]
  624. for prop in properties:
  625. prop.print_openapi(6, None, None)
  626. required_properties = [f.name for f in properties if f.required]
  627. if required_properties:
  628. print(' required:')
  629. for f in required_properties:
  630. print(' - {}'.format(f))
  631. # then print the references
  632. current = None
  633. required_properties = []
  634. properties = [f for f in self.fields if '.' in f.name and not '$' in f.name]
  635. for prop in properties:
  636. current = prop.print_openapi(6, current, required_properties)
  637. if required_properties:
  638. print(' required:')
  639. for f in required_properties:
  640. print(' - {}'.format(f))
  641. required_properties = []
  642. # then print the references in the references
  643. for prop in [f for f in self.fields if '.' in f.name and '$' in f.name]:
  644. current = prop.print_openapi(6, current, required_properties)
  645. if required_properties:
  646. print(' required:')
  647. for f in required_properties:
  648. print(' - {}'.format(f))
  649. class Context(object):
  650. def __init__(self, path):
  651. self.path = path
  652. with open(path) as f:
  653. self._txt = f.readlines()
  654. data = ''.join(self._txt)
  655. self.program = esprima.parseModule(data,
  656. options={
  657. 'comment': True,
  658. 'loc': True
  659. })
  660. def txt_for(self, statement):
  661. return self.text_at(statement.loc.start.line, statement.loc.end.line)
  662. def text_at(self, begin, end):
  663. return ''.join(self._txt[begin - 1:end])
  664. def parse_file(path):
  665. try:
  666. # if the file failed, it's likely it doesn't contain a schema
  667. context = Context(path)
  668. except:
  669. return
  670. return context
  671. def parse_schemas(schemas_dir):
  672. schemas = {}
  673. entry_points = []
  674. for root, dirs, files in os.walk(schemas_dir):
  675. files.sort()
  676. for filename in files:
  677. path = os.path.join(root, filename)
  678. context = parse_file(path)
  679. if context is None:
  680. # the file doesn't contain a schema (see above)
  681. continue
  682. program = context.program
  683. current_schema = None
  684. jsdocs = [c for c in program.comments
  685. if c.type == 'Block' and c.value.startswith('*\n')]
  686. try:
  687. for statement in program.body:
  688. # find the '<ITEM>.attachSchema(new SimpleSchema(<data>)'
  689. # those are the schemas
  690. if (statement.type == 'ExpressionStatement' and
  691. statement.expression.callee is not None and
  692. statement.expression.callee.property is not None and
  693. statement.expression.callee.property.name == 'attachSchema' and
  694. statement.expression.arguments[0].type == 'NewExpression' and
  695. statement.expression.arguments[0].callee.name == 'SimpleSchema'):
  696. schema = Schemas(context, statement, jsdocs)
  697. current_schema = schema.name
  698. schemas[current_schema] = schema
  699. # find all the 'if (Meteor.isServer) { JsonRoutes.add('
  700. # those are the entry points of the API
  701. elif (statement.type == 'IfStatement' and
  702. statement.test.type == 'MemberExpression' and
  703. statement.test.object.name == 'Meteor' and
  704. statement.test.property.name == 'isServer'):
  705. data = [s.expression.arguments
  706. for s in statement.consequent.body
  707. if (s.type == 'ExpressionStatement' and
  708. s.expression.type == 'CallExpression' and
  709. s.expression.callee.object.name == 'JsonRoutes')]
  710. # we found at least one entry point, keep them
  711. if len(data) > 0:
  712. if current_schema is None:
  713. current_schema = filename
  714. schemas[current_schema] = Schemas(context, name=current_schema)
  715. schema_entry_points = [EntryPoint(schemas[current_schema], d)
  716. for d in data]
  717. entry_points.extend(schema_entry_points)
  718. end_of_previous_operation = -1
  719. # try to match JSDoc to the operations
  720. for entry_point in schema_entry_points:
  721. operation = entry_point.method # POST/GET/PUT/DELETE
  722. # find all jsdocs that end before the current operation,
  723. # the last item in the list is the one we need
  724. jsdoc = [j for j in jsdocs
  725. if j.loc.end.line + 1 <= operation.loc.start.line and
  726. j.loc.start.line > end_of_previous_operation]
  727. if bool(jsdoc):
  728. entry_point.doc = jsdoc[-1]
  729. end_of_previous_operation = operation.loc.end.line
  730. except TypeError:
  731. logger.warning(context.txt_for(statement))
  732. logger.error('{}:{}-{} can not parse {}'.format(path,
  733. statement.loc.start.line,
  734. statement.loc.end.line,
  735. statement.type))
  736. raise
  737. return schemas, entry_points
  738. def generate_openapi(schemas, entry_points, version):
  739. print('''swagger: '2.0'
  740. info:
  741. title: Wekan REST API
  742. version: {0}
  743. description: |
  744. The REST API allows you to control and extend Wekan with ease.
  745. If you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.
  746. > All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.
  747. # Production Security Concerns
  748. When calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
  749. * Only call via HTTPS
  750. * Implement a timed authorization token expiration strategy
  751. * Ensure the calling user only has permissions for what they are calling and no more
  752. schemes:
  753. - http
  754. securityDefinitions:
  755. UserSecurity:
  756. type: apiKey
  757. in: header
  758. name: Authorization
  759. paths:
  760. /users/login:
  761. post:
  762. operationId: login
  763. summary: Login with REST API
  764. consumes:
  765. - application/x-www-form-urlencoded
  766. - application/json
  767. tags:
  768. - Login
  769. parameters:
  770. - name: username
  771. in: formData
  772. required: true
  773. description: |
  774. Your username
  775. type: string
  776. - name: password
  777. in: formData
  778. required: true
  779. description: |
  780. Your password
  781. type: string
  782. format: password
  783. responses:
  784. 200:
  785. description: |-
  786. Successful authentication
  787. schema:
  788. items:
  789. properties:
  790. id:
  791. type: string
  792. token:
  793. type: string
  794. tokenExpires:
  795. type: string
  796. 400:
  797. description: |
  798. Error in authentication
  799. schema:
  800. items:
  801. properties:
  802. error:
  803. type: number
  804. reason:
  805. type: string
  806. default:
  807. description: |
  808. Error in authentication
  809. /users/register:
  810. post:
  811. operationId: register
  812. summary: Register with REST API
  813. description: |
  814. Notes:
  815. - You will need to provide the token for any of the authenticated methods.
  816. consumes:
  817. - application/x-www-form-urlencoded
  818. - application/json
  819. tags:
  820. - Login
  821. parameters:
  822. - name: username
  823. in: formData
  824. required: true
  825. description: |
  826. Your username
  827. type: string
  828. - name: password
  829. in: formData
  830. required: true
  831. description: |
  832. Your password
  833. type: string
  834. format: password
  835. - name: email
  836. in: formData
  837. required: true
  838. description: |
  839. Your email
  840. type: string
  841. responses:
  842. 200:
  843. description: |-
  844. Successful registration
  845. schema:
  846. items:
  847. properties:
  848. id:
  849. type: string
  850. token:
  851. type: string
  852. tokenExpires:
  853. type: string
  854. 400:
  855. description: |
  856. Error in registration
  857. schema:
  858. items:
  859. properties:
  860. error:
  861. type: number
  862. reason:
  863. type: string
  864. default:
  865. description: |
  866. Error in registration
  867. '''.format(version))
  868. # GET and POST on the same path are valid, we need to reshuffle the paths
  869. # with the path as the sorting key
  870. methods = {}
  871. for ep in entry_points:
  872. if ep.path not in methods:
  873. methods[ep.path] = []
  874. methods[ep.path].append(ep)
  875. sorted_paths = list(methods.keys())
  876. sorted_paths.sort()
  877. for path in sorted_paths:
  878. print(' {}:'.format(methods[path][0].url))
  879. for ep in methods[path]:
  880. ep.print_openapi()
  881. print('definitions:')
  882. for schema in schemas.values():
  883. # do not export the objects if there is no API attached
  884. if not schema.used:
  885. continue
  886. schema.print_openapi()
  887. def main():
  888. parser = argparse.ArgumentParser(description='Generate an OpenAPI 2.0 from the given JS schemas.')
  889. script_dir = os.path.dirname(os.path.realpath(__file__))
  890. parser.add_argument('--release', default='git-master', nargs=1,
  891. help='the current version of the API, can be retrieved by running `git describe --tags --abbrev=0`')
  892. parser.add_argument('dir', default=os.path.abspath('{}/../models'.format(script_dir)), nargs='?',
  893. help='the directory where to look for schemas')
  894. args = parser.parse_args()
  895. schemas, entry_points = parse_schemas(args.dir)
  896. generate_openapi(schemas, entry_points, args.release[0])
  897. if __name__ == '__main__':
  898. main()