abstract.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. """
  2. This module defines an AbstractAPI, which defines a standardized interface for a Connexion API.
  3. """
  4. import abc
  5. import logging
  6. import pathlib
  7. import sys
  8. import typing as t
  9. import warnings
  10. from enum import Enum
  11. from ..decorators.produces import NoContent
  12. from ..exceptions import ResolverError
  13. from ..http_facts import METHODS
  14. from ..jsonifier import Jsonifier
  15. from ..lifecycle import ConnexionResponse
  16. from ..operations import make_operation
  17. from ..options import ConnexionOptions
  18. from ..resolver import Resolver
  19. from ..spec import Specification
  20. from ..utils import is_json_mimetype
  21. MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent
  22. SWAGGER_UI_URL = 'ui'
  23. logger = logging.getLogger('connexion.apis.abstract')
  24. class AbstractAPIMeta(abc.ABCMeta):
  25. def __init__(cls, name, bases, attrs):
  26. abc.ABCMeta.__init__(cls, name, bases, attrs)
  27. cls._set_jsonifier()
  28. class AbstractAPI(metaclass=AbstractAPIMeta):
  29. """
  30. Defines an abstract interface for a Swagger API
  31. """
  32. def __init__(self, specification, base_path=None, arguments=None,
  33. validate_responses=False, strict_validation=False, resolver=None,
  34. auth_all_paths=False, debug=False, resolver_error_handler=None,
  35. validator_map=None, pythonic_params=False, pass_context_arg_name=None, options=None,
  36. ):
  37. """
  38. :type specification: pathlib.Path | dict
  39. :type base_path: str | None
  40. :type arguments: dict | None
  41. :type validate_responses: bool
  42. :type strict_validation: bool
  43. :type auth_all_paths: bool
  44. :type debug: bool
  45. :param validator_map: Custom validators for the types "parameter", "body" and "response".
  46. :type validator_map: dict
  47. :param resolver: Callable that maps operationID to a function
  48. :param resolver_error_handler: If given, a callable that generates an
  49. Operation used for handling ResolveErrors
  50. :type resolver_error_handler: callable | None
  51. :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended
  52. to any shadowed built-ins
  53. :type pythonic_params: bool
  54. :param options: New style options dictionary.
  55. :type options: dict | None
  56. :param pass_context_arg_name: If not None URL request handling functions with an argument matching this name
  57. will be passed the framework's request context.
  58. :type pass_context_arg_name: str | None
  59. """
  60. self.debug = debug
  61. self.validator_map = validator_map
  62. self.resolver_error_handler = resolver_error_handler
  63. logger.debug('Loading specification: %s', specification,
  64. extra={'swagger_yaml': specification,
  65. 'base_path': base_path,
  66. 'arguments': arguments,
  67. 'auth_all_paths': auth_all_paths})
  68. # Avoid validator having ability to modify specification
  69. self.specification = Specification.load(specification, arguments=arguments)
  70. logger.debug('Read specification', extra={'spec': self.specification})
  71. self.options = ConnexionOptions(options, oas_version=self.specification.version)
  72. logger.debug('Options Loaded',
  73. extra={'swagger_ui': self.options.openapi_console_ui_available,
  74. 'swagger_path': self.options.openapi_console_ui_from_dir,
  75. 'swagger_url': self.options.openapi_console_ui_path})
  76. self._set_base_path(base_path)
  77. logger.debug('Security Definitions: %s', self.specification.security_definitions)
  78. self.resolver = resolver or Resolver()
  79. logger.debug('Validate Responses: %s', str(validate_responses))
  80. self.validate_responses = validate_responses
  81. logger.debug('Strict Request Validation: %s', str(strict_validation))
  82. self.strict_validation = strict_validation
  83. logger.debug('Pythonic params: %s', str(pythonic_params))
  84. self.pythonic_params = pythonic_params
  85. logger.debug('pass_context_arg_name: %s', pass_context_arg_name)
  86. self.pass_context_arg_name = pass_context_arg_name
  87. self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name)
  88. if self.options.openapi_spec_available:
  89. self.add_openapi_json()
  90. self.add_openapi_yaml()
  91. if self.options.openapi_console_ui_available:
  92. self.add_swagger_ui()
  93. self.add_paths()
  94. if auth_all_paths:
  95. self.add_auth_on_not_found(
  96. self.specification.security,
  97. self.specification.security_definitions
  98. )
  99. def _set_base_path(self, base_path=None):
  100. if base_path is not None:
  101. # update spec to include user-provided base_path
  102. self.specification.base_path = base_path
  103. self.base_path = base_path
  104. else:
  105. self.base_path = self.specification.base_path
  106. @abc.abstractmethod
  107. def add_openapi_json(self):
  108. """
  109. Adds openapi spec to {base_path}/openapi.json
  110. (or {base_path}/swagger.json for swagger2)
  111. """
  112. @abc.abstractmethod
  113. def add_swagger_ui(self):
  114. """
  115. Adds swagger ui to {base_path}/ui/
  116. """
  117. @abc.abstractmethod
  118. def add_auth_on_not_found(self, security, security_definitions):
  119. """
  120. Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass.
  121. """
  122. @staticmethod
  123. @abc.abstractmethod
  124. def make_security_handler_factory(pass_context_arg_name):
  125. """ Create SecurityHandlerFactory to create all security check handlers """
  126. def add_operation(self, path, method):
  127. """
  128. Adds one operation to the api.
  129. This method uses the OperationID identify the module and function that will handle the operation
  130. From Swagger Specification:
  131. **OperationID**
  132. A friendly name for the operation. The id MUST be unique among all operations described in the API.
  133. Tools and libraries MAY use the operation id to uniquely identify an operation.
  134. :type method: str
  135. :type path: str
  136. """
  137. operation = make_operation(
  138. self.specification,
  139. self,
  140. path,
  141. method,
  142. self.resolver,
  143. validate_responses=self.validate_responses,
  144. validator_map=self.validator_map,
  145. strict_validation=self.strict_validation,
  146. pythonic_params=self.pythonic_params,
  147. uri_parser_class=self.options.uri_parser_class,
  148. pass_context_arg_name=self.pass_context_arg_name
  149. )
  150. self._add_operation_internal(method, path, operation)
  151. @abc.abstractmethod
  152. def _add_operation_internal(self, method, path, operation):
  153. """
  154. Adds the operation according to the user framework in use.
  155. It will be used to register the operation on the user framework router.
  156. """
  157. def _add_resolver_error_handler(self, method, path, err):
  158. """
  159. Adds a handler for ResolverError for the given method and path.
  160. """
  161. operation = self.resolver_error_handler(
  162. err,
  163. security=self.specification.security,
  164. security_definitions=self.specification.security_definitions
  165. )
  166. self._add_operation_internal(method, path, operation)
  167. def add_paths(self, paths=None):
  168. """
  169. Adds the paths defined in the specification as endpoints
  170. :type paths: list
  171. """
  172. paths = paths or self.specification.get('paths', dict())
  173. for path, methods in paths.items():
  174. logger.debug('Adding %s%s...', self.base_path, path)
  175. for method in methods:
  176. if method not in METHODS:
  177. continue
  178. try:
  179. self.add_operation(path, method)
  180. except ResolverError as err:
  181. # If we have an error handler for resolver errors, add it as an operation.
  182. # Otherwise treat it as any other error.
  183. if self.resolver_error_handler is not None:
  184. self._add_resolver_error_handler(method, path, err)
  185. else:
  186. self._handle_add_operation_error(path, method, err.exc_info)
  187. except Exception:
  188. # All other relevant exceptions should be handled as well.
  189. self._handle_add_operation_error(path, method, sys.exc_info())
  190. def _handle_add_operation_error(self, path, method, exc_info):
  191. url = f'{self.base_path}{path}'
  192. error_msg = 'Failed to add operation for {method} {url}'.format(
  193. method=method.upper(),
  194. url=url)
  195. if self.debug:
  196. logger.exception(error_msg)
  197. else:
  198. logger.error(error_msg)
  199. _type, value, traceback = exc_info
  200. raise value.with_traceback(traceback)
  201. @classmethod
  202. @abc.abstractmethod
  203. def get_request(self, *args, **kwargs):
  204. """
  205. This method converts the user framework request to a ConnexionRequest.
  206. """
  207. @classmethod
  208. @abc.abstractmethod
  209. def get_response(self, response, mimetype=None, request=None):
  210. """
  211. This method converts a handler response to a framework response.
  212. This method should just retrieve response from handler then call `cls._get_response`.
  213. It is mainly here to handle AioHttp async handler.
  214. :param response: A response to cast (tuple, framework response, etc).
  215. :param mimetype: The response mimetype.
  216. :type mimetype: Union[None, str]
  217. :param request: The request associated with this response (the user framework request).
  218. """
  219. @classmethod
  220. def _get_response(cls, response, mimetype=None, extra_context=None):
  221. """
  222. This method converts a handler response to a framework response.
  223. The response can be a ConnexionResponse, an operation handler, a framework response or a tuple.
  224. Other type than ConnexionResponse are handled by `cls._response_from_handler`
  225. :param response: A response to cast (tuple, framework response, etc).
  226. :param mimetype: The response mimetype.
  227. :type mimetype: Union[None, str]
  228. :param extra_context: dict of extra details, like url, to include in logs
  229. :type extra_context: Union[None, dict]
  230. """
  231. if extra_context is None:
  232. extra_context = {}
  233. logger.debug('Getting data and status code',
  234. extra={
  235. 'data': response,
  236. 'data_type': type(response),
  237. **extra_context
  238. })
  239. if isinstance(response, ConnexionResponse):
  240. framework_response = cls._connexion_to_framework_response(response, mimetype, extra_context)
  241. else:
  242. framework_response = cls._response_from_handler(response, mimetype, extra_context)
  243. logger.debug('Got framework response',
  244. extra={
  245. 'response': framework_response,
  246. 'response_type': type(framework_response),
  247. **extra_context
  248. })
  249. return framework_response
  250. @classmethod
  251. def _response_from_handler(
  252. cls,
  253. response: t.Union[t.Any, str, t.Tuple[str], t.Tuple[str, int], t.Tuple[str, int, dict]],
  254. mimetype: str,
  255. extra_context: t.Optional[dict] = None
  256. ) -> t.Any:
  257. """
  258. Create a framework response from the operation handler data.
  259. An operation handler can return:
  260. - a framework response
  261. - a body (str / binary / dict / list), a response will be created
  262. with a status code 200 by default and empty headers.
  263. - a tuple of (body: str, status_code: int)
  264. - a tuple of (body: str, status_code: int, headers: dict)
  265. :param response: A response from an operation handler.
  266. :param mimetype: The response mimetype.
  267. :param extra_context: dict of extra details, like url, to include in logs
  268. """
  269. if cls._is_framework_response(response):
  270. return response
  271. if isinstance(response, tuple):
  272. len_response = len(response)
  273. if len_response == 1:
  274. data, = response
  275. return cls._build_response(mimetype=mimetype, data=data, extra_context=extra_context)
  276. if len_response == 2:
  277. if isinstance(response[1], (int, Enum)):
  278. data, status_code = response
  279. return cls._build_response(mimetype=mimetype, data=data, status_code=status_code, extra_context=extra_context)
  280. else:
  281. data, headers = response
  282. return cls._build_response(mimetype=mimetype, data=data, headers=headers, extra_context=extra_context)
  283. elif len_response == 3:
  284. data, status_code, headers = response
  285. return cls._build_response(mimetype=mimetype, data=data, status_code=status_code, headers=headers, extra_context=extra_context)
  286. else:
  287. raise TypeError(
  288. 'The view function did not return a valid response tuple.'
  289. ' The tuple must have the form (body), (body, status, headers),'
  290. ' (body, status), or (body, headers).'
  291. )
  292. else:
  293. return cls._build_response(mimetype=mimetype, data=response, extra_context=extra_context)
  294. @classmethod
  295. def get_connexion_response(cls, response, mimetype=None):
  296. """ Cast framework dependent response to ConnexionResponse used for schema validation """
  297. if isinstance(response, ConnexionResponse):
  298. # If body in ConnexionResponse is not byte, it may not pass schema validation.
  299. # In this case, rebuild response with aiohttp to have consistency
  300. if response.body is None or isinstance(response.body, bytes):
  301. return response
  302. else:
  303. response = cls._build_response(
  304. data=response.body,
  305. mimetype=mimetype,
  306. content_type=response.content_type,
  307. headers=response.headers,
  308. status_code=response.status_code
  309. )
  310. if not cls._is_framework_response(response):
  311. response = cls._response_from_handler(response, mimetype)
  312. return cls._framework_to_connexion_response(response=response, mimetype=mimetype)
  313. @classmethod
  314. @abc.abstractmethod
  315. def _is_framework_response(cls, response):
  316. """ Return True if `response` is a framework response class """
  317. @classmethod
  318. @abc.abstractmethod
  319. def _framework_to_connexion_response(cls, response, mimetype):
  320. """ Cast framework response class to ConnexionResponse used for schema validation """
  321. @classmethod
  322. @abc.abstractmethod
  323. def _connexion_to_framework_response(cls, response, mimetype, extra_context=None):
  324. """ Cast ConnexionResponse to framework response class """
  325. @classmethod
  326. @abc.abstractmethod
  327. def _build_response(cls, data, mimetype, content_type=None, status_code=None, headers=None, extra_context=None):
  328. """
  329. Create a framework response from the provided arguments.
  330. :param data: Body data.
  331. :param content_type: The response mimetype.
  332. :type content_type: str
  333. :param content_type: The response status code.
  334. :type status_code: int
  335. :param headers: The response status code.
  336. :type headers: Union[Iterable[Tuple[str, str]], Dict[str, str]]
  337. :param extra_context: dict of extra details, like url, to include in logs
  338. :type extra_context: Union[None, dict]
  339. :return A framework response.
  340. :rtype Response
  341. """
  342. @classmethod
  343. def _prepare_body_and_status_code(cls, data, mimetype, status_code=None, extra_context=None):
  344. if data is NoContent:
  345. data = None
  346. if status_code is None:
  347. if data is None:
  348. status_code = 204
  349. mimetype = None
  350. else:
  351. status_code = 200
  352. elif hasattr(status_code, "value"):
  353. # If we got an enum instead of an int, extract the value.
  354. status_code = status_code.value
  355. if data is not None:
  356. body, mimetype = cls._serialize_data(data, mimetype)
  357. else:
  358. body = data
  359. if extra_context is None:
  360. extra_context = {}
  361. logger.debug('Prepared body and status code (%d)',
  362. status_code,
  363. extra={
  364. 'body': body,
  365. **extra_context
  366. })
  367. return body, status_code, mimetype
  368. @classmethod
  369. def _serialize_data(cls, data, mimetype):
  370. # TODO: Harmonize with flask_api. Currently this is the backwards compatible with aiohttp_api._cast_body.
  371. if not isinstance(data, bytes):
  372. if isinstance(mimetype, str) and is_json_mimetype(mimetype):
  373. body = cls.jsonifier.dumps(data)
  374. elif isinstance(data, str):
  375. body = data
  376. else:
  377. warnings.warn(
  378. "Implicit (aiohttp) serialization with str() will change in the next major version. "
  379. "This is triggered because a non-JSON response body is being stringified. "
  380. "This will be replaced by something that is mimetype-specific and may "
  381. "serialize some things as JSON or throw an error instead of silently "
  382. "stringifying unknown response bodies. "
  383. "Please make sure to specify media/mime types in your specs.",
  384. FutureWarning # a Deprecation targeted at application users.
  385. )
  386. body = str(data)
  387. else:
  388. body = data
  389. return body, mimetype
  390. def json_loads(self, data):
  391. return self.jsonifier.loads(data)
  392. @classmethod
  393. def _set_jsonifier(cls):
  394. cls.jsonifier = Jsonifier()