generate_openapi.py 35 KB

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