2
0

generate_openapi.py 31 KB

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