doc.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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_file:
  141. method_operations.setdefault('produces', []).extend(
  142. description.output_file.wrapper.output_types
  143. )
  144. method_operations.setdefault('responses', {})\
  145. [int(description.output_file.wrapper.default_http_code)] = {
  146. 'description': str(int(description.output_file.wrapper.default_http_code)), # nopep8
  147. }
  148. if description.errors:
  149. for error in description.errors:
  150. schema_class = type(error.wrapper.error_builder)
  151. method_operations.setdefault('responses', {})\
  152. [int(error.wrapper.http_code)] = {
  153. 'description': str(int(error.wrapper.http_code)),
  154. 'schema': {
  155. '$ref': '#/definitions/{}'.format(
  156. spec.schema_name_resolver(schema_class)
  157. )
  158. }
  159. }
  160. # jsonschema based
  161. if description.input_path:
  162. schema_class = spec.schema_class_resolver(
  163. spec,
  164. description.input_path.wrapper.processor.schema
  165. )
  166. # TODO: look schema2parameters ?
  167. jsonschema = schema2jsonschema(schema_class, spec=spec)
  168. for name, schema in jsonschema.get('properties', {}).items():
  169. method_operations.setdefault('parameters', []).append(
  170. generate_fields_description(
  171. schema=schema,
  172. in_='path',
  173. name=name,
  174. required=name in jsonschema.get('required', []),
  175. )
  176. )
  177. if description.input_query:
  178. schema_class = spec.schema_class_resolver(
  179. spec,
  180. description.input_query.wrapper.processor.schema
  181. )
  182. jsonschema = schema2jsonschema(schema_class, spec=spec)
  183. for name, schema in jsonschema.get('properties', {}).items():
  184. method_operations.setdefault('parameters', []).append(
  185. generate_fields_description(
  186. schema=schema,
  187. in_='query',
  188. name=name,
  189. required=name in jsonschema.get('required', []),
  190. )
  191. )
  192. if description.input_files:
  193. method_operations.setdefault('consumes', []).append('multipart/form-data')
  194. for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
  195. # TODO - G.M - 01-06-2018 - Check if other fields can be used
  196. # see generate_fields_description
  197. method_operations.setdefault('parameters', []).append({
  198. 'in': 'formData',
  199. 'name': field_name,
  200. 'required': field.required,
  201. 'type': 'file',
  202. })
  203. if description.tags:
  204. method_operations['tags'] = description.tags
  205. operations = {
  206. route.method.lower(): method_operations,
  207. }
  208. return operations
  209. class DocGenerator(object):
  210. def get_doc(
  211. self,
  212. controllers: typing.List[DecoratedController],
  213. context: ContextInterface,
  214. title: str='',
  215. description: str='',
  216. ) -> dict:
  217. """
  218. Generate an OpenApi 2.0 documentation. Th given context will be used
  219. to found controllers matching with given DecoratedController.
  220. :param controllers: List of DecoratedController to match with context
  221. controllers
  222. :param context: a context instance
  223. :param title: The generated doc title
  224. :param description: The generated doc description
  225. :return: a apispec documentation dict
  226. """
  227. spec = APISpec(
  228. title=title,
  229. info=dict(description=description),
  230. version='1.0.0',
  231. plugins=(
  232. 'apispec.ext.marshmallow',
  233. ),
  234. auto_referencing=True,
  235. schema_name_resolver=generate_schema_name
  236. )
  237. schemas = []
  238. # parse schemas
  239. for controller in controllers:
  240. description = controller.description
  241. if description.input_body:
  242. schemas.append(spec.schema_class_resolver(
  243. spec,
  244. description.input_body.wrapper.processor.schema
  245. ))
  246. if description.input_forms:
  247. schemas.append(spec.schema_class_resolver(
  248. spec,
  249. description.input_forms.wrapper.processor.schema
  250. ))
  251. if description.output_body:
  252. schemas.append(spec.schema_class_resolver(
  253. spec,
  254. description.output_body.wrapper.processor.schema
  255. ))
  256. if description.errors:
  257. for error in description.errors:
  258. schemas.append(type(error.wrapper.error_builder))
  259. for schema in set(schemas):
  260. spec.definition(
  261. spec.schema_name_resolver(schema),
  262. schema=schema
  263. )
  264. # add views
  265. # with app.test_request_context():
  266. paths = {}
  267. for controller in controllers:
  268. route = context.find_route(controller)
  269. swagger_path = context.get_swagger_path(route.rule)
  270. operations = bottle_generate_operations(
  271. spec,
  272. route,
  273. controller.description,
  274. )
  275. # TODO BS 20171114: TMP code waiting refact of doc
  276. doc_string = controller.reference.get_doc_string()
  277. if doc_string:
  278. for method in operations.keys():
  279. operations[method]['description'] = doc_string
  280. path = Path(path=swagger_path, operations=operations)
  281. if swagger_path in paths:
  282. paths[swagger_path].update(path)
  283. else:
  284. paths[swagger_path] = path
  285. spec.add_path(path)
  286. return spec.to_dict()
  287. def get_doc_yaml(
  288. self,
  289. controllers: typing.List[DecoratedController],
  290. context: ContextInterface,
  291. title: str = '',
  292. description: str = '',
  293. ) -> str:
  294. dict_doc = self.get_doc(
  295. controllers=controllers,
  296. context=context,
  297. title=title,
  298. description=description,
  299. )
  300. json_doc = json.dumps(dict_doc)
  301. # We dump then load with json to use real scalar dict.
  302. # If not, yaml dump dict-like objects
  303. clean_dict_doc = json.loads(json_doc)
  304. return yaml.dump(clean_dict_doc, default_flow_style=False)
  305. def save_in_file(
  306. self,
  307. doc_file_path: str,
  308. controllers: typing.List[DecoratedController],
  309. context: ContextInterface,
  310. title: str='',
  311. description: str='',
  312. ) -> None:
  313. doc_yaml = self.get_doc_yaml(
  314. controllers=controllers,
  315. context=context,
  316. title=title,
  317. description=description,
  318. )
  319. with open(doc_file_path, 'w+') as doc_file:
  320. doc_file.write(doc_yaml)
  321. # TODO BS 20171109: Must take care of already existing definition names
  322. def generate_schema_name(schema: marshmallow.Schema):
  323. """
  324. Return best candidate name for one schema cls or instance.
  325. :param schema: instance or cls schema
  326. :return: best schema name
  327. """
  328. if not isinstance(schema, type):
  329. schema = type(schema)
  330. if getattr(schema, '_schema_name', None):
  331. if schema.opts.exclude:
  332. schema_name = "{}_without".format(schema.__name__)
  333. for elem in sorted(schema.opts.exclude):
  334. schema_name="{}_{}".format(schema_name, elem)
  335. else:
  336. schema_name = schema._schema_name
  337. else:
  338. schema_name = schema.__name__
  339. return schema_name