hapic.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. # -*- coding: utf-8 -*-
  2. import os
  3. import typing
  4. import uuid
  5. import functools
  6. try: # Python 3.5+
  7. from http import HTTPStatus
  8. except ImportError:
  9. from http import client as HTTPStatus
  10. from hapic.buffer import DecorationBuffer
  11. from hapic.context import ContextInterface
  12. from hapic.decorator import DecoratedController
  13. from hapic.decorator import DECORATION_ATTRIBUTE_NAME
  14. from hapic.decorator import ControllerReference
  15. from hapic.decorator import ExceptionHandlerControllerWrapper
  16. from hapic.decorator import InputBodyControllerWrapper
  17. from hapic.decorator import InputHeadersControllerWrapper
  18. from hapic.decorator import InputPathControllerWrapper
  19. from hapic.decorator import InputQueryControllerWrapper
  20. from hapic.decorator import InputFilesControllerWrapper
  21. from hapic.decorator import OutputBodyControllerWrapper
  22. from hapic.decorator import OutputHeadersControllerWrapper
  23. from hapic.decorator import OutputFileControllerWrapper
  24. from hapic.description import InputBodyDescription
  25. from hapic.description import ErrorDescription
  26. from hapic.description import InputFormsDescription
  27. from hapic.description import InputHeadersDescription
  28. from hapic.description import InputPathDescription
  29. from hapic.description import InputQueryDescription
  30. from hapic.description import InputFilesDescription
  31. from hapic.description import OutputBodyDescription
  32. from hapic.description import OutputHeadersDescription
  33. from hapic.description import OutputFileDescription
  34. from hapic.doc import DocGenerator
  35. from hapic.processor import ProcessorInterface
  36. from hapic.processor import MarshmallowInputProcessor
  37. from hapic.processor import MarshmallowInputFilesProcessor
  38. from hapic.processor import MarshmallowOutputProcessor
  39. from hapic.error import ErrorBuilderInterface
  40. # TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb), see #12
  41. # TODO: Confusion nommage body/json/forms, see #13
  42. class Hapic(object):
  43. def __init__(self):
  44. self._buffer = DecorationBuffer()
  45. self._controllers = [] # type: typing.List[DecoratedController]
  46. self._context = None # type: ContextInterface
  47. self._error_builder = None # type: ErrorBuilderInterface
  48. self.doc_generator = DocGenerator()
  49. # This local function will be pass to different components
  50. # who will need context but declared (like with decorator)
  51. # before context declaration
  52. def context_getter():
  53. return self._context
  54. # This local function will be pass to different components
  55. # who will need error_builder but declared (like with decorator)
  56. # before error_builder declaration
  57. def error_builder_getter():
  58. return self._context.get_default_error_builder()
  59. self._context_getter = context_getter
  60. self._error_builder_getter = error_builder_getter
  61. # TODO: Permettre la surcharge des classes utilisés ci-dessous, see #14
  62. @property
  63. def controllers(self) -> typing.List[DecoratedController]:
  64. return self._controllers
  65. @property
  66. def context(self) -> ContextInterface:
  67. return self._context
  68. def set_context(self, context: ContextInterface) -> None:
  69. assert not self._context
  70. self._context = context
  71. def reset_context(self) -> None:
  72. self._context = None
  73. def with_api_doc(self, tags: typing.List['str']=None):
  74. """
  75. Permit to generate doc about a controller. Use as a decorator:
  76. ```
  77. @hapic.with_api_doc()
  78. def my_controller(self):
  79. # ...
  80. ```
  81. What it do: Register this controller with all previous given
  82. information like `@hapic.input_path(...)` etc.
  83. :param tags: list of string tags (OpenApi)
  84. :return: The decorator
  85. """
  86. # FIXME BS 20171228: Documenter sur ce que ça fait vraiment (tester:
  87. # on peut l'enlever si on veut pas generer la doc ?)
  88. tags = tags or [] # FDV
  89. def decorator(func):
  90. @functools.wraps(func)
  91. def wrapper(*args, **kwargs):
  92. return func(*args, **kwargs)
  93. token = uuid.uuid4().hex
  94. setattr(wrapper, DECORATION_ATTRIBUTE_NAME, token)
  95. setattr(func, DECORATION_ATTRIBUTE_NAME, token)
  96. description = self._buffer.get_description()
  97. description.tags = tags
  98. reference = ControllerReference(
  99. wrapper=wrapper,
  100. wrapped=func,
  101. token=token,
  102. )
  103. decorated_controller = DecoratedController(
  104. reference=reference,
  105. description=description,
  106. name=func.__name__,
  107. )
  108. self._buffer.clear()
  109. self._controllers.append(decorated_controller)
  110. return wrapper
  111. return decorator
  112. def output_body(
  113. self,
  114. schema: typing.Any,
  115. processor: ProcessorInterface = None,
  116. context: ContextInterface = None,
  117. error_http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
  118. default_http_code: HTTPStatus = HTTPStatus.OK,
  119. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  120. processor = processor or MarshmallowOutputProcessor()
  121. processor.schema = schema
  122. context = context or self._context_getter
  123. decoration = OutputBodyControllerWrapper(
  124. context=context,
  125. processor=processor,
  126. error_http_code=error_http_code,
  127. default_http_code=default_http_code,
  128. )
  129. def decorator(func):
  130. self._buffer.output_body = OutputBodyDescription(decoration)
  131. return decoration.get_wrapper(func)
  132. return decorator
  133. def output_headers(
  134. self,
  135. schema: typing.Any,
  136. processor: ProcessorInterface = None,
  137. context: ContextInterface = None,
  138. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  139. default_http_code: HTTPStatus = HTTPStatus.OK,
  140. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  141. processor = processor or MarshmallowOutputProcessor()
  142. processor.schema = schema
  143. context = context or self._context_getter
  144. decoration = OutputHeadersControllerWrapper(
  145. context=context,
  146. processor=processor,
  147. error_http_code=error_http_code,
  148. default_http_code=default_http_code,
  149. )
  150. def decorator(func):
  151. self._buffer.output_headers = OutputHeadersDescription(decoration)
  152. return decoration.get_wrapper(func)
  153. return decorator
  154. # TODO BS 20171102: Think about possibilities to validate output ?
  155. # (with mime type, or validator)
  156. def output_file(
  157. self,
  158. output_types: typing.List[str],
  159. default_http_code: HTTPStatus = HTTPStatus.OK,
  160. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  161. decoration = OutputFileControllerWrapper(
  162. output_types=output_types,
  163. default_http_code=default_http_code,
  164. )
  165. def decorator(func):
  166. self._buffer.output_file = OutputFileDescription(decoration)
  167. return decoration.get_wrapper(func)
  168. return decorator
  169. def input_headers(
  170. self,
  171. schema: typing.Any,
  172. processor: ProcessorInterface = None,
  173. context: ContextInterface = None,
  174. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  175. default_http_code: HTTPStatus = HTTPStatus.OK,
  176. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  177. processor = processor or MarshmallowInputProcessor()
  178. processor.schema = schema
  179. context = context or self._context_getter
  180. decoration = InputHeadersControllerWrapper(
  181. context=context,
  182. processor=processor,
  183. error_http_code=error_http_code,
  184. default_http_code=default_http_code,
  185. )
  186. def decorator(func):
  187. self._buffer.input_headers = InputHeadersDescription(decoration)
  188. return decoration.get_wrapper(func)
  189. return decorator
  190. def input_path(
  191. self,
  192. schema: typing.Any,
  193. processor: ProcessorInterface = None,
  194. context: ContextInterface = None,
  195. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  196. default_http_code: HTTPStatus = HTTPStatus.OK,
  197. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  198. processor = processor or MarshmallowInputProcessor()
  199. processor.schema = schema
  200. context = context or self._context_getter
  201. decoration = InputPathControllerWrapper(
  202. context=context,
  203. processor=processor,
  204. error_http_code=error_http_code,
  205. default_http_code=default_http_code,
  206. )
  207. def decorator(func):
  208. self._buffer.input_path = InputPathDescription(decoration)
  209. return decoration.get_wrapper(func)
  210. return decorator
  211. def input_query(
  212. self,
  213. schema: typing.Any,
  214. processor: ProcessorInterface = None,
  215. context: ContextInterface = None,
  216. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  217. default_http_code: HTTPStatus = HTTPStatus.OK,
  218. as_list: typing.List[str]=None,
  219. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  220. processor = processor or MarshmallowInputProcessor()
  221. processor.schema = schema
  222. context = context or self._context_getter
  223. decoration = InputQueryControllerWrapper(
  224. context=context,
  225. processor=processor,
  226. error_http_code=error_http_code,
  227. default_http_code=default_http_code,
  228. as_list=as_list,
  229. )
  230. def decorator(func):
  231. self._buffer.input_query = InputQueryDescription(decoration)
  232. return decoration.get_wrapper(func)
  233. return decorator
  234. def input_body(
  235. self,
  236. schema: typing.Any,
  237. processor: ProcessorInterface = None,
  238. context: ContextInterface = None,
  239. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  240. default_http_code: HTTPStatus = HTTPStatus.OK,
  241. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  242. processor = processor or MarshmallowInputProcessor()
  243. processor.schema = schema
  244. context = context or self._context_getter
  245. decoration = InputBodyControllerWrapper(
  246. context=context,
  247. processor=processor,
  248. error_http_code=error_http_code,
  249. default_http_code=default_http_code,
  250. )
  251. def decorator(func):
  252. self._buffer.input_body = InputBodyDescription(decoration)
  253. return decoration.get_wrapper(func)
  254. return decorator
  255. def input_forms(
  256. self,
  257. schema: typing.Any,
  258. processor: ProcessorInterface=None,
  259. context: ContextInterface=None,
  260. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  261. default_http_code: HTTPStatus = HTTPStatus.OK,
  262. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  263. processor = processor or MarshmallowInputProcessor()
  264. processor.schema = schema
  265. context = context or self._context_getter
  266. decoration = InputBodyControllerWrapper(
  267. context=context,
  268. processor=processor,
  269. error_http_code=error_http_code,
  270. default_http_code=default_http_code,
  271. )
  272. def decorator(func):
  273. self._buffer.input_forms = InputFormsDescription(decoration)
  274. return decoration.get_wrapper(func)
  275. return decorator
  276. def input_files(
  277. self,
  278. schema: typing.Any,
  279. processor: ProcessorInterface=None,
  280. context: ContextInterface=None,
  281. error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
  282. default_http_code: HTTPStatus = HTTPStatus.OK,
  283. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  284. processor = processor or MarshmallowInputFilesProcessor()
  285. processor.schema = schema
  286. context = context or self._context_getter
  287. decoration = InputFilesControllerWrapper(
  288. context=context,
  289. processor=processor,
  290. error_http_code=error_http_code,
  291. default_http_code=default_http_code,
  292. )
  293. def decorator(func):
  294. self._buffer.input_files = InputFilesDescription(decoration)
  295. return decoration.get_wrapper(func)
  296. return decorator
  297. def handle_exception(
  298. self,
  299. handled_exception_class: typing.Type[Exception]=Exception,
  300. http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
  301. error_builder: ErrorBuilderInterface=None,
  302. context: ContextInterface = None,
  303. ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
  304. context = context or self._context_getter
  305. error_builder = error_builder or self._error_builder_getter
  306. decoration = ExceptionHandlerControllerWrapper(
  307. handled_exception_class,
  308. context,
  309. error_builder=error_builder,
  310. http_code=http_code,
  311. )
  312. def decorator(func):
  313. self._buffer.errors.append(ErrorDescription(decoration))
  314. return decoration.get_wrapper(func)
  315. return decorator
  316. def generate_doc(self, title: str='', description: str='') -> dict:
  317. """
  318. See hapic.doc.DocGenerator#get_doc docstring
  319. :param title: Title of generated doc
  320. :param description: Description of generated doc
  321. :return: dict containing apispec doc
  322. """
  323. return self.doc_generator.get_doc(
  324. self._controllers,
  325. self.context,
  326. title=title,
  327. description=description,
  328. )
  329. def save_doc_in_file(
  330. self,
  331. file_path: str,
  332. title: str='',
  333. description: str='',
  334. ) -> None:
  335. """
  336. See hapic.doc.DocGenerator#get_doc docstring
  337. :param file_path: The file path to write doc in YAML format
  338. :param title: Title of generated doc
  339. :param description: Description of generated doc
  340. """
  341. self.doc_generator.save_in_file(
  342. file_path,
  343. controllers=self._controllers,
  344. context=self.context,
  345. title=title,
  346. description=description,
  347. )
  348. def add_documentation_view(
  349. self,
  350. route: str,
  351. title: str='',
  352. description: str='',
  353. ) -> None:
  354. # Ensure "/" at end of route, else web browser will not consider it as
  355. # a path
  356. if not route.endswith('/'):
  357. route = '{}/'.format(route)
  358. # Add swagger directory as served static dir
  359. swaggerui_path = os.path.join(
  360. os.path.dirname(os.path.abspath(__file__)),
  361. 'static',
  362. 'swaggerui',
  363. )
  364. self.context.serve_directory(
  365. route,
  366. swaggerui_path,
  367. )
  368. # Generate documentation file
  369. doc_page_path = os.path.join(swaggerui_path, 'spec.yml')
  370. self.save_doc_in_file(doc_page_path)
  371. # Prepare views html content
  372. doc_index_path = os.path.join(swaggerui_path, 'index.html')
  373. with open(doc_index_path, 'r') as doc_page:
  374. doc_page_content = doc_page.read()
  375. doc_page_content = doc_page_content.replace(
  376. '{{ spec_uri }}',
  377. 'spec.yml',
  378. )
  379. # Declare the swaggerui view
  380. def api_doc_view():
  381. return self.context.get_response(
  382. doc_page_content,
  383. http_code=HTTPStatus.OK,
  384. mimetype='text/html',
  385. )
  386. # Add a view to generate the html index page of swaggerui
  387. self.context.add_view(
  388. route=route,
  389. http_method='GET',
  390. view_func=api_doc_view,
  391. )