generate_openapi.py 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  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 == 'FunctionExpression':
  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. for p in statement.value.properties:
  395. try:
  396. if p.key.name == 'type':
  397. if p.value.type == 'Identifier':
  398. self.type = p.value.name.lower()
  399. elif p.value.type == 'ArrayExpression':
  400. self.type = 'array'
  401. self.elements = [e.name.lower() for e in p.value.elements]
  402. elif p.key.name == 'allowedValues':
  403. self.type = 'enum'
  404. if p.value.type == 'ArrayExpression':
  405. self.enum = [e.value.lower() for e in p.value.elements]
  406. elif p.value.type == 'Identifier':
  407. # tree wide lookout for the identifier
  408. def find_variable(elem, match):
  409. if isinstance(elem, list):
  410. for value in elem:
  411. ret = find_variable(value, match)
  412. if ret is not None:
  413. return ret
  414. try:
  415. items = elem.items()
  416. except AttributeError:
  417. return None
  418. except TypeError:
  419. return None
  420. if (elem.type == 'VariableDeclarator' and
  421. elem.id.name == match):
  422. return elem
  423. for type, value in items:
  424. ret = find_variable(value, match)
  425. if ret is not None:
  426. return ret
  427. return None
  428. elem = find_variable(context.program.body, p.value.name)
  429. if elem.init.type != 'ArrayExpression':
  430. raise TypeError('can not find "{}"'.format(p.value.name))
  431. self.enum = [e.value.lower() for e in elem.init.elements]
  432. elif p.key.name == 'blackbox':
  433. self.blackbox = True
  434. elif p.key.name == 'optional' and p.value.value:
  435. self.required = False
  436. except Exception:
  437. input = ''
  438. for line in range(p.loc.start.line - err_context, p.loc.end.line + 1 + err_context):
  439. if line < p.loc.start.line or line > p.loc.end.line:
  440. input += '. '
  441. else:
  442. input += '>>'
  443. input += context.text_at(line, line)
  444. input = ''.join(input)
  445. logger.error('{}:{}-{} can not parse {}:\n{}'.format(context.path,
  446. p.loc.start.line,
  447. p.loc.end.line,
  448. p.type,
  449. input))
  450. logger.error('esprima tree:\n{}'.format(p))
  451. logger.error(traceback.format_exc())
  452. sys.exit(1)
  453. self._doc = None
  454. self._raw_doc = None
  455. @property
  456. def doc(self):
  457. return self._doc
  458. @doc.setter
  459. def doc(self, jsdoc):
  460. self._raw_doc = jsdoc
  461. self._doc = cleanup_jsdocs(jsdoc)
  462. def process_jsdocs(self, jsdocs):
  463. start = self.statement.key.loc.start.line
  464. for index, doc in enumerate(jsdocs):
  465. if start + 1 == doc.loc.start.line:
  466. self.doc = doc
  467. jsdocs.pop(index)
  468. return
  469. def __repr__(self):
  470. return 'SchemaProperty({}{}, {})'.format(self.name,
  471. '*' if self.required else '',
  472. self.doc)
  473. def print_openapi(self, indent, current_schema, required_properties):
  474. schema_name = self.schema.name
  475. name = self.name
  476. # deal with subschemas
  477. if '.' in name:
  478. if name.endswith('$'):
  479. # reference in reference
  480. subschema = ''.join([n.capitalize() for n in self.name.split('.')[:-1]])
  481. subschema = self.schema.name + subschema
  482. if current_schema != subschema:
  483. if required_properties is not None and required_properties:
  484. print(' required:')
  485. for f in required_properties:
  486. print(' - {}'.format(f))
  487. required_properties.clear()
  488. print(''' {}:
  489. type: object'''.format(subschema))
  490. return current_schema
  491. subschema = name.split('.')[0]
  492. schema_name = self.schema.name + subschema.capitalize()
  493. name = name.split('.')[-1]
  494. if current_schema != schema_name:
  495. if required_properties is not None and required_properties:
  496. print(' required:')
  497. for f in required_properties:
  498. print(' - {}'.format(f))
  499. required_properties.clear()
  500. print(''' {}:
  501. type: object
  502. properties:'''.format(schema_name))
  503. if required_properties is not None and self.required:
  504. required_properties.append(name)
  505. print('{}{}:'.format(' ' * indent, name))
  506. if self.doc is not None:
  507. print('{} description: |'.format(' ' * indent))
  508. for line in self.doc:
  509. if line.strip():
  510. print('{} {}'.format(' ' * indent, line))
  511. else:
  512. print('')
  513. ptype = self.type
  514. if ptype in ('enum', 'date'):
  515. ptype = 'string'
  516. if ptype != 'object':
  517. print('{} type: {}'.format(' ' * indent, ptype))
  518. if self.type == 'array':
  519. print('{} items:'.format(' ' * indent))
  520. for elem in self.elements:
  521. if elem == 'object':
  522. print('{} $ref: "#/definitions/{}"'.format(' ' * indent, schema_name + name.capitalize()))
  523. else:
  524. print('{} type: {}'.format(' ' * indent, elem))
  525. if not self.required:
  526. print('{} x-nullable: true'.format(' ' * indent))
  527. elif self.type == 'object':
  528. if self.blackbox:
  529. print('{} type: object'.format(' ' * indent))
  530. else:
  531. print('{} $ref: "#/definitions/{}"'.format(' ' * indent, schema_name + name.capitalize()))
  532. elif self.type == 'enum':
  533. print('{} enum:'.format(' ' * indent))
  534. for enum in self.enum:
  535. print('{} - {}'.format(' ' * indent, enum))
  536. if '.' not in self.name and not self.required:
  537. print('{} x-nullable: true'.format(' ' * indent))
  538. return schema_name
  539. class Schemas(object):
  540. def __init__(self, context, data=None, jsdocs=None, name=None):
  541. self.name = name
  542. self._data = data
  543. self.fields = None
  544. self.used = False
  545. if data is not None:
  546. if self.name is None:
  547. self.name = data.expression.callee.object.name
  548. content = data.expression.arguments[0].arguments[0]
  549. self.fields = [SchemaProperty(p, self, context) for p in content.properties]
  550. self._doc = None
  551. self._raw_doc = None
  552. if jsdocs is not None:
  553. self.process_jsdocs(jsdocs)
  554. @property
  555. def doc(self):
  556. if self._doc is None:
  557. return None
  558. return ' '.join(self._doc)
  559. @doc.setter
  560. def doc(self, jsdoc):
  561. self._raw_doc = jsdoc
  562. self._doc = cleanup_jsdocs(jsdoc)
  563. def process_jsdocs(self, jsdocs):
  564. start = self._data.loc.start.line
  565. end = self._data.loc.end.line
  566. for doc in jsdocs:
  567. if doc.loc.end.line + 1 == start:
  568. self.doc = doc
  569. docs = [doc
  570. for doc in jsdocs
  571. if doc.loc.start.line >= start and doc.loc.end.line <= end]
  572. for field in self.fields:
  573. field.process_jsdocs(docs)
  574. def print_openapi(self):
  575. # empty schemas are skipped
  576. if self.fields is None:
  577. return
  578. print(' {}:'.format(self.name))
  579. print(' type: object')
  580. if self.doc is not None:
  581. print(' description: {}'.format(self.doc))
  582. print(' properties:')
  583. # first print out the object itself
  584. properties = [field for field in self.fields if '.' not in field.name]
  585. for prop in properties:
  586. prop.print_openapi(6, None, None)
  587. required_properties = [f.name for f in properties if f.required]
  588. if required_properties:
  589. print(' required:')
  590. for f in required_properties:
  591. print(' - {}'.format(f))
  592. # then print the references
  593. current = None
  594. required_properties = []
  595. properties = [f for f in self.fields if '.' in f.name and not f.name.endswith('$')]
  596. for prop in properties:
  597. current = prop.print_openapi(6, current, required_properties)
  598. if required_properties:
  599. print(' required:')
  600. for f in required_properties:
  601. print(' - {}'.format(f))
  602. required_properties = []
  603. # then print the references in the references
  604. for prop in [f for f in self.fields if '.' in f.name and f.name.endswith('$')]:
  605. current = prop.print_openapi(6, current, required_properties)
  606. if required_properties:
  607. print(' required:')
  608. for f in required_properties:
  609. print(' - {}'.format(f))
  610. class Context(object):
  611. def __init__(self, path):
  612. self.path = path
  613. with open(path) as f:
  614. self._txt = f.readlines()
  615. data = ''.join(self._txt)
  616. self.program = esprima.parseModule(data,
  617. options={
  618. 'comment': True,
  619. 'loc': True
  620. })
  621. def txt_for(self, statement):
  622. return self.text_at(statement.loc.start.line, statement.loc.end.line)
  623. def text_at(self, begin, end):
  624. return ''.join(self._txt[begin - 1:end])
  625. def parse_schemas(schemas_dir):
  626. schemas = {}
  627. entry_points = []
  628. for root, dirs, files in os.walk(schemas_dir):
  629. files.sort()
  630. for filename in files:
  631. path = os.path.join(root, filename)
  632. try:
  633. # if the file failed, it's likely it doesn't contain a schema
  634. context = Context(path)
  635. except:
  636. continue
  637. program = context.program
  638. current_schema = None
  639. jsdocs = [c for c in program.comments
  640. if c.type == 'Block' and c.value.startswith('*\n')]
  641. try:
  642. for statement in program.body:
  643. # find the '<ITEM>.attachSchema(new SimpleSchema(<data>)'
  644. # those are the schemas
  645. if (statement.type == 'ExpressionStatement' and
  646. statement.expression.callee is not None and
  647. statement.expression.callee.property is not None and
  648. statement.expression.callee.property.name == 'attachSchema' and
  649. statement.expression.arguments[0].type == 'NewExpression' and
  650. statement.expression.arguments[0].callee.name == 'SimpleSchema'):
  651. schema = Schemas(context, statement, jsdocs)
  652. current_schema = schema.name
  653. schemas[current_schema] = schema
  654. # find all the 'if (Meteor.isServer) { JsonRoutes.add('
  655. # those are the entry points of the API
  656. elif (statement.type == 'IfStatement' and
  657. statement.test.type == 'MemberExpression' and
  658. statement.test.object.name == 'Meteor' and
  659. statement.test.property.name == 'isServer'):
  660. data = [s.expression.arguments
  661. for s in statement.consequent.body
  662. if (s.type == 'ExpressionStatement' and
  663. s.expression.type == 'CallExpression' and
  664. s.expression.callee.object.name == 'JsonRoutes')]
  665. # we found at least one entry point, keep them
  666. if len(data) > 0:
  667. if current_schema is None:
  668. current_schema = filename
  669. schemas[current_schema] = Schemas(context, name=current_schema)
  670. schema_entry_points = [EntryPoint(schemas[current_schema], d)
  671. for d in data]
  672. entry_points.extend(schema_entry_points)
  673. end_of_previous_operation = -1
  674. # try to match JSDoc to the operations
  675. for entry_point in schema_entry_points:
  676. operation = entry_point.method # POST/GET/PUT/DELETE
  677. # find all jsdocs that end before the current operation,
  678. # the last item in the list is the one we need
  679. jsdoc = [j for j in jsdocs
  680. if j.loc.end.line + 1 <= operation.loc.start.line and
  681. j.loc.start.line > end_of_previous_operation]
  682. if bool(jsdoc):
  683. entry_point.doc = jsdoc[-1]
  684. end_of_previous_operation = operation.loc.end.line
  685. except TypeError:
  686. logger.warning(context.txt_for(statement))
  687. logger.error('{}:{}-{} can not parse {}'.format(path,
  688. statement.loc.start.line,
  689. statement.loc.end.line,
  690. statement.type))
  691. raise
  692. return schemas, entry_points
  693. def generate_openapi(schemas, entry_points, version):
  694. print('''swagger: '2.0'
  695. info:
  696. title: Wekan REST API
  697. version: {0}
  698. description: |
  699. The REST API allows you to control and extend Wekan with ease.
  700. 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.
  701. > 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.
  702. # Production Security Concerns
  703. 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:
  704. * Only call via HTTPS
  705. * Implement a timed authorization token expiration strategy
  706. * Ensure the calling user only has permissions for what they are calling and no more
  707. schemes:
  708. - http
  709. securityDefinitions:
  710. UserSecurity:
  711. type: apiKey
  712. in: header
  713. name: Authorization
  714. paths:
  715. /users/login:
  716. post:
  717. operationId: login
  718. summary: Login with REST API
  719. consumes:
  720. - application/x-www-form-urlencoded
  721. - application/json
  722. tags:
  723. - Login
  724. parameters:
  725. - name: username
  726. in: formData
  727. required: true
  728. description: |
  729. Your username
  730. type: string
  731. - name: password
  732. in: formData
  733. required: true
  734. description: |
  735. Your password
  736. type: string
  737. format: password
  738. responses:
  739. 200:
  740. description: |-
  741. Successful authentication
  742. schema:
  743. items:
  744. properties:
  745. id:
  746. type: string
  747. token:
  748. type: string
  749. tokenExpires:
  750. type: string
  751. 400:
  752. description: |
  753. Error in authentication
  754. schema:
  755. items:
  756. properties:
  757. error:
  758. type: number
  759. reason:
  760. type: string
  761. default:
  762. description: |
  763. Error in authentication
  764. /users/register:
  765. post:
  766. operationId: register
  767. summary: Register with REST API
  768. description: |
  769. Notes:
  770. - You will need to provide the token for any of the authenticated methods.
  771. consumes:
  772. - application/x-www-form-urlencoded
  773. - application/json
  774. tags:
  775. - Login
  776. parameters:
  777. - name: username
  778. in: formData
  779. required: true
  780. description: |
  781. Your username
  782. type: string
  783. - name: password
  784. in: formData
  785. required: true
  786. description: |
  787. Your password
  788. type: string
  789. format: password
  790. - name: email
  791. in: formData
  792. required: true
  793. description: |
  794. Your email
  795. type: string
  796. responses:
  797. 200:
  798. description: |-
  799. Successful registration
  800. schema:
  801. items:
  802. properties:
  803. id:
  804. type: string
  805. token:
  806. type: string
  807. tokenExpires:
  808. type: string
  809. 400:
  810. description: |
  811. Error in registration
  812. schema:
  813. items:
  814. properties:
  815. error:
  816. type: number
  817. reason:
  818. type: string
  819. default:
  820. description: |
  821. Error in registration
  822. '''.format(version))
  823. # GET and POST on the same path are valid, we need to reshuffle the paths
  824. # with the path as the sorting key
  825. methods = {}
  826. for ep in entry_points:
  827. if ep.path not in methods:
  828. methods[ep.path] = []
  829. methods[ep.path].append(ep)
  830. sorted_paths = list(methods.keys())
  831. sorted_paths.sort()
  832. for path in sorted_paths:
  833. print(' {}:'.format(methods[path][0].url))
  834. for ep in methods[path]:
  835. ep.print_openapi()
  836. print('definitions:')
  837. for schema in schemas.values():
  838. # do not export the objects if there is no API attached
  839. if not schema.used:
  840. continue
  841. schema.print_openapi()
  842. def main():
  843. parser = argparse.ArgumentParser(description='Generate an OpenAPI 2.0 from the given JS schemas.')
  844. script_dir = os.path.dirname(os.path.realpath(__file__))
  845. parser.add_argument('--release', default='git-master', nargs=1,
  846. help='the current version of the API, can be retrieved by running `git describe --tags --abbrev=0`')
  847. parser.add_argument('dir', default='{}/../models'.format(script_dir), nargs='?',
  848. help='the directory where to look for schemas')
  849. args = parser.parse_args()
  850. schemas, entry_points = parse_schemas(args.dir)
  851. generate_openapi(schemas, entry_points, args.release[0])
  852. if __name__ == '__main__':
  853. main()