generate_openapi.py 39 KB

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