generate_openapi.py 31 KB

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