generate_openapi.py 39 KB

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