doc.py 12KB

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