generate_openapi.py 39 KB

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