doc.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. # -*- coding: utf-8 -*-
  2. import json
  3. import typing
  4. import marshmallow
  5. import yaml
  6. from apispec import APISpec
  7. from apispec import Path
  8. from apispec.ext.marshmallow.swagger import schema2jsonschema
  9. from hapic.context import ContextInterface
  10. from hapic.context import RouteRepresentation
  11. from hapic.decorator import DecoratedController
  12. from hapic.description import ControllerDescription
  13. FIELDS_PARAMS_GENERIC_ACCEPTED = [
  14. 'type',
  15. 'format',
  16. 'required',
  17. 'description',
  18. 'enum',
  19. ]
  20. FIELDS_TYPE_ARRAY = [
  21. 'array',
  22. ]
  23. FIELDS_PARAMS_ARRAY_ACCEPTED = [
  24. 'items',
  25. 'collectionFormat',
  26. 'pattern',
  27. 'maxitems',
  28. 'minitems',
  29. 'uniqueitems',
  30. ]
  31. FIELDS_TYPE_STRING = [
  32. 'string',
  33. ]
  34. FIELDS_PARAMS_STRING_ACCEPTED = [
  35. 'maxLength',
  36. 'minLength',
  37. 'pattern',
  38. ]
  39. FIELDS_TYPE_NUMERIC = [
  40. 'number',
  41. 'integer',
  42. ]
  43. FIELDS_PARAMS_NUMERIC_ACCEPTED = [
  44. 'maximum',
  45. 'exclusiveMaximum',
  46. 'minimum',
  47. 'exclusiveMinimum',
  48. 'multipleOf',
  49. ]
  50. def generate_schema_ref(spec:APISpec, schema: marshmallow.Schema) -> str:
  51. schema_class = spec.schema_class_resolver(
  52. spec,
  53. schema
  54. )
  55. ref = {
  56. '$ref': '#/definitions/{}'.format(
  57. spec.schema_name_resolver(schema_class)
  58. )
  59. }
  60. if schema.many:
  61. ref = {
  62. 'type': 'array',
  63. 'items': ref
  64. }
  65. return ref
  66. def field_accepted_param(type: str, param_name:str) -> bool:
  67. return (
  68. param_name in FIELDS_PARAMS_GENERIC_ACCEPTED
  69. or (type in FIELDS_TYPE_STRING
  70. and param_name in FIELDS_PARAMS_STRING_ACCEPTED)
  71. or (type in FIELDS_TYPE_ARRAY
  72. and param_name in FIELDS_PARAMS_ARRAY_ACCEPTED)
  73. or (type in FIELDS_TYPE_NUMERIC
  74. and param_name in FIELDS_PARAMS_NUMERIC_ACCEPTED)
  75. )
  76. def generate_fields_description(
  77. schema,
  78. in_: str,
  79. name: str,
  80. required: bool,
  81. type: str=None,
  82. ) -> dict:
  83. """
  84. Generate field OpenApiDescription for
  85. both query and path params
  86. :param schema: field schema
  87. :param in_: in field
  88. :param name: field name
  89. :param required: required field
  90. :param type: type field
  91. :return: File description for OpenApi
  92. """
  93. description = {}
  94. # INFO - G.M - 01-06-2018 - get field
  95. # type to know which params are accepted
  96. if not type and 'type' in schema:
  97. type = schema['type']
  98. assert type
  99. for param_name, value in schema.items():
  100. if field_accepted_param(type, param_name):
  101. description[param_name] = value
  102. description['type'] = type
  103. description['in'] = in_
  104. description['name'] = name
  105. description['required'] = required
  106. # INFO - G.M - 01-06-2018 - example is not allowed in query/path params,
  107. # in OpenApi2, remove it and set it as string in field description.
  108. if 'example' in schema:
  109. if 'description' not in description:
  110. description['description'] = ""
  111. description['description'] = '{description}\n\n*example value: {example}*'.format( # nopep8
  112. description=description['description'],
  113. example=schema['example']
  114. )
  115. return description
  116. def bottle_generate_operations(
  117. spec,
  118. route: RouteRepresentation,
  119. description: ControllerDescription,
  120. ):
  121. method_operations = dict()
  122. if description.input_body:
  123. method_operations.setdefault('parameters', []).append({
  124. 'in': 'body',
  125. 'name': 'body',
  126. 'schema': generate_schema_ref(
  127. spec,
  128. description.input_body.wrapper.processor.schema,
  129. )
  130. })
  131. if description.output_body:
  132. method_operations.setdefault('responses', {})\
  133. [int(description.output_body.wrapper.default_http_code)] = {
  134. 'description': str(int(description.output_body.wrapper.default_http_code)), # nopep8
  135. 'schema': generate_schema_ref(
  136. spec,
  137. description.output_body.wrapper.processor.schema,
  138. )
  139. }
  140. if description.output_stream:
  141. # TODO BS 2018-07-31: Is that a correct way to re
  142. # instanciate with .__class__ ... ?
  143. method_operations.setdefault('responses', {})\
  144. [int(description.output_stream.wrapper.default_http_code)] = {
  145. 'description': str(int(description.output_stream.wrapper.default_http_code)), # nopep8
  146. 'schema': generate_schema_ref(
  147. spec,
  148. description
  149. .output_stream
  150. .wrapper
  151. .processor
  152. .schema
  153. .__class__(many=True),
  154. )
  155. }
  156. if description.output_file:
  157. method_operations.setdefault('produces', []).extend(
  158. description.output_file.wrapper.output_types
  159. )
  160. method_operations.setdefault('responses', {})\
  161. [int(description.output_file.wrapper.default_http_code)] = {
  162. 'description': str(int(description.output_file.wrapper.default_http_code)), # nopep8
  163. }
  164. if description.errors:
  165. for error in description.errors:
  166. schema_class = type(error.wrapper.error_builder)
  167. method_operations.setdefault('responses', {})\
  168. [int(error.wrapper.http_code)] = {
  169. 'description': str(int(error.wrapper.http_code)),
  170. 'schema': {
  171. '$ref': '#/definitions/{}'.format(
  172. spec.schema_name_resolver(schema_class)
  173. )
  174. }
  175. }
  176. # jsonschema based
  177. if description.input_path:
  178. schema_class = spec.schema_class_resolver(
  179. spec,
  180. description.input_path.wrapper.processor.schema
  181. )
  182. # TODO: look schema2parameters ?
  183. jsonschema = schema2jsonschema(schema_class, spec=spec)
  184. for name, schema in jsonschema.get('properties', {}).items():
  185. method_operations.setdefault('parameters', []).append(
  186. generate_fields_description(
  187. schema=schema,
  188. in_='path',
  189. name=name,
  190. required=name in jsonschema.get('required', []),
  191. )
  192. )
  193. if description.input_query:
  194. schema_class = spec.schema_class_resolver(
  195. spec,
  196. description.input_query.wrapper.processor.schema
  197. )
  198. jsonschema = schema2jsonschema(schema_class, spec=spec)
  199. for name, schema in jsonschema.get('properties', {}).items():
  200. method_operations.setdefault('parameters', []).append(
  201. generate_fields_description(
  202. schema=schema,
  203. in_='query',
  204. name=name,
  205. required=name in jsonschema.get('required', []),
  206. )
  207. )
  208. if description.input_files:
  209. method_operations.setdefault('consumes', []).append('multipart/form-data')
  210. for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
  211. # TODO - G.M - 01-06-2018 - Check if other fields can be used
  212. # see generate_fields_description
  213. method_operations.setdefault('parameters', []).append({
  214. 'in': 'formData',
  215. 'name': field_name,
  216. 'required': field.required,
  217. 'type': 'file',
  218. })
  219. if description.tags:
  220. method_operations['tags'] = description.tags
  221. operations = {
  222. route.method.lower(): method_operations,
  223. }
  224. return operations
  225. class DocGenerator(object):
  226. def get_doc(
  227. self,
  228. controllers: typing.List[DecoratedController],
  229. context: ContextInterface,
  230. title: str='',
  231. description: str='',
  232. ) -> dict:
  233. """
  234. Generate an OpenApi 2.0 documentation. Th given context will be used
  235. to found controllers matching with given DecoratedController.
  236. :param controllers: List of DecoratedController to match with context
  237. controllers
  238. :param context: a context instance
  239. :param title: The generated doc title
  240. :param description: The generated doc description
  241. :return: a apispec documentation dict
  242. """
  243. spec = APISpec(
  244. title=title,
  245. info=dict(description=description),
  246. version='1.0.0',
  247. plugins=(
  248. 'apispec.ext.marshmallow',
  249. ),
  250. auto_referencing=True,
  251. schema_name_resolver=generate_schema_name
  252. )
  253. schemas = []
  254. # parse schemas
  255. for controller in controllers:
  256. description = controller.description
  257. if description.input_body:
  258. schemas.append(spec.schema_class_resolver(
  259. spec,
  260. description.input_body.wrapper.processor.schema
  261. ))
  262. if description.input_forms:
  263. schemas.append(spec.schema_class_resolver(
  264. spec,
  265. description.input_forms.wrapper.processor.schema
  266. ))
  267. if description.output_body:
  268. schemas.append(spec.schema_class_resolver(
  269. spec,
  270. description.output_body.wrapper.processor.schema
  271. ))
  272. if description.errors:
  273. for error in description.errors:
  274. schemas.append(type(error.wrapper.error_builder))
  275. for schema in set(schemas):
  276. spec.definition(
  277. spec.schema_name_resolver(schema),
  278. schema=schema
  279. )
  280. # add views
  281. # with app.test_request_context():
  282. paths = {}
  283. for controller in controllers:
  284. route = context.find_route(controller)
  285. swagger_path = context.get_swagger_path(route.rule)
  286. operations = bottle_generate_operations(
  287. spec,
  288. route,
  289. controller.description,
  290. )
  291. # TODO BS 20171114: TMP code waiting refact of doc
  292. doc_string = controller.reference.get_doc_string()
  293. if doc_string:
  294. for method in operations.keys():
  295. operations[method]['description'] = doc_string
  296. path = Path(path=swagger_path, operations=operations)
  297. if swagger_path in paths:
  298. paths[swagger_path].update(path)
  299. else:
  300. paths[swagger_path] = path
  301. spec.add_path(path)
  302. return spec.to_dict()
  303. def get_doc_yaml(
  304. self,
  305. controllers: typing.List[DecoratedController],
  306. context: ContextInterface,
  307. title: str = '',
  308. description: str = '',
  309. ) -> str:
  310. dict_doc = self.get_doc(
  311. controllers=controllers,
  312. context=context,
  313. title=title,
  314. description=description,
  315. )
  316. json_doc = json.dumps(dict_doc)
  317. # We dump then load with json to use real scalar dict.
  318. # If not, yaml dump dict-like objects
  319. clean_dict_doc = json.loads(json_doc)
  320. return yaml.dump(clean_dict_doc, default_flow_style=False)
  321. def save_in_file(
  322. self,
  323. doc_file_path: str,
  324. controllers: typing.List[DecoratedController],
  325. context: ContextInterface,
  326. title: str='',
  327. description: str='',
  328. ) -> None:
  329. doc_yaml = self.get_doc_yaml(
  330. controllers=controllers,
  331. context=context,
  332. title=title,
  333. description=description,
  334. )
  335. with open(doc_file_path, 'w+') as doc_file:
  336. doc_file.write(doc_yaml)
  337. # TODO BS 20171109: Must take care of already existing definition names
  338. def generate_schema_name(schema: marshmallow.Schema):
  339. """
  340. Return best candidate name for one schema cls or instance.
  341. :param schema: instance or cls schema
  342. :return: best schema name
  343. """
  344. if not isinstance(schema, type):
  345. schema = type(schema)
  346. if getattr(schema, '_schema_name', None):
  347. if schema.opts.exclude:
  348. schema_name = "{}_without".format(schema.__name__)
  349. for elem in sorted(schema.opts.exclude):
  350. schema_name="{}_{}".format(schema_name, elem)
  351. else:
  352. schema_name = schema._schema_name
  353. else:
  354. schema_name = schema.__name__
  355. return schema_name