generate_openapi.py 32 KB

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