flask_api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """
  2. This module defines a Flask Connexion API which implements translations between Flask and
  3. Connexion requests / responses.
  4. """
  5. import logging
  6. import pathlib
  7. import warnings
  8. from typing import Any
  9. import flask
  10. import werkzeug.exceptions
  11. from werkzeug.local import LocalProxy
  12. from connexion.apis import flask_utils
  13. from connexion.apis.abstract import AbstractAPI
  14. from connexion.handlers import AuthErrorHandler
  15. from connexion.jsonifier import Jsonifier
  16. from connexion.lifecycle import ConnexionRequest, ConnexionResponse
  17. from connexion.security import FlaskSecurityHandlerFactory
  18. from connexion.utils import is_json_mimetype, yamldumper
  19. logger = logging.getLogger('connexion.apis.flask_api')
  20. class FlaskApi(AbstractAPI):
  21. @staticmethod
  22. def make_security_handler_factory(pass_context_arg_name):
  23. """ Create default SecurityHandlerFactory to create all security check handlers """
  24. return FlaskSecurityHandlerFactory(pass_context_arg_name)
  25. def _set_base_path(self, base_path):
  26. super()._set_base_path(base_path)
  27. self._set_blueprint()
  28. def _set_blueprint(self):
  29. logger.debug('Creating API blueprint: %s', self.base_path)
  30. endpoint = flask_utils.flaskify_endpoint(self.base_path)
  31. self.blueprint = flask.Blueprint(endpoint, __name__, url_prefix=self.base_path,
  32. template_folder=str(self.options.openapi_console_ui_from_dir))
  33. def add_openapi_json(self):
  34. """
  35. Adds spec json to {base_path}/swagger.json
  36. or {base_path}/openapi.json (for oas3)
  37. """
  38. logger.debug('Adding spec json: %s/%s', self.base_path,
  39. self.options.openapi_spec_path)
  40. endpoint_name = f"{self.blueprint.name}_openapi_json"
  41. self.blueprint.add_url_rule(self.options.openapi_spec_path,
  42. endpoint_name,
  43. self._handlers.get_json_spec)
  44. def add_openapi_yaml(self):
  45. """
  46. Adds spec yaml to {base_path}/swagger.yaml
  47. or {base_path}/openapi.yaml (for oas3)
  48. """
  49. if not self.options.openapi_spec_path.endswith("json"):
  50. return
  51. openapi_spec_path_yaml = \
  52. self.options.openapi_spec_path[:-len("json")] + "yaml"
  53. logger.debug('Adding spec yaml: %s/%s', self.base_path,
  54. openapi_spec_path_yaml)
  55. endpoint_name = f"{self.blueprint.name}_openapi_yaml"
  56. self.blueprint.add_url_rule(
  57. openapi_spec_path_yaml,
  58. endpoint_name,
  59. self._handlers.get_yaml_spec
  60. )
  61. def add_swagger_ui(self):
  62. """
  63. Adds swagger ui to {base_path}/ui/
  64. """
  65. console_ui_path = self.options.openapi_console_ui_path.strip('/')
  66. logger.debug('Adding swagger-ui: %s/%s/',
  67. self.base_path,
  68. console_ui_path)
  69. if self.options.openapi_console_ui_config is not None:
  70. config_endpoint_name = f"{self.blueprint.name}_swagger_ui_config"
  71. config_file_url = '/{console_ui_path}/swagger-ui-config.json'.format(
  72. console_ui_path=console_ui_path)
  73. self.blueprint.add_url_rule(config_file_url,
  74. config_endpoint_name,
  75. lambda: flask.jsonify(self.options.openapi_console_ui_config))
  76. static_endpoint_name = f"{self.blueprint.name}_swagger_ui_static"
  77. static_files_url = '/{console_ui_path}/<path:filename>'.format(
  78. console_ui_path=console_ui_path)
  79. self.blueprint.add_url_rule(static_files_url,
  80. static_endpoint_name,
  81. self._handlers.console_ui_static_files)
  82. index_endpoint_name = f"{self.blueprint.name}_swagger_ui_index"
  83. console_ui_url = '/{console_ui_path}/'.format(
  84. console_ui_path=console_ui_path)
  85. self.blueprint.add_url_rule(console_ui_url,
  86. index_endpoint_name,
  87. self._handlers.console_ui_home)
  88. def add_auth_on_not_found(self, security, security_definitions):
  89. """
  90. Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass.
  91. """
  92. logger.debug('Adding path not found authentication')
  93. not_found_error = AuthErrorHandler(self, werkzeug.exceptions.NotFound(), security=security,
  94. security_definitions=security_definitions)
  95. endpoint_name = f"{self.blueprint.name}_not_found"
  96. self.blueprint.add_url_rule('/<path:invalid_path>', endpoint_name, not_found_error.function)
  97. def _add_operation_internal(self, method, path, operation):
  98. operation_id = operation.operation_id
  99. logger.debug('... Adding %s -> %s', method.upper(), operation_id,
  100. extra=vars(operation))
  101. flask_path = flask_utils.flaskify_path(path, operation.get_path_parameter_types())
  102. endpoint_name = flask_utils.flaskify_endpoint(operation.operation_id,
  103. operation.randomize_endpoint)
  104. function = operation.function
  105. self.blueprint.add_url_rule(flask_path, endpoint_name, function, methods=[method])
  106. @property
  107. def _handlers(self):
  108. # type: () -> InternalHandlers
  109. if not hasattr(self, '_internal_handlers'):
  110. self._internal_handlers = InternalHandlers(self.base_path, self.options, self.specification)
  111. return self._internal_handlers
  112. @classmethod
  113. def get_response(cls, response, mimetype=None, request=None):
  114. """Gets ConnexionResponse instance for the operation handler
  115. result. Status Code and Headers for response. If only body
  116. data is returned by the endpoint function, then the status
  117. code will be set to 200 and no headers will be added.
  118. If the returned object is a flask.Response then it will just
  119. pass the information needed to recreate it.
  120. :type response: flask.Response | (flask.Response,) | (flask.Response, int) | (flask.Response, dict) | (flask.Response, int, dict)
  121. :rtype: ConnexionResponse
  122. """
  123. return cls._get_response(response, mimetype=mimetype, extra_context={"url": flask.request.url})
  124. @classmethod
  125. def _is_framework_response(cls, response):
  126. """ Return True if provided response is a framework type """
  127. return flask_utils.is_flask_response(response)
  128. @classmethod
  129. def _framework_to_connexion_response(cls, response, mimetype):
  130. """ Cast framework response class to ConnexionResponse used for schema validation """
  131. return ConnexionResponse(
  132. status_code=response.status_code,
  133. mimetype=response.mimetype,
  134. content_type=response.content_type,
  135. headers=response.headers,
  136. body=response.get_data() if not response.direct_passthrough else None,
  137. is_streamed=response.is_streamed
  138. )
  139. @classmethod
  140. def _connexion_to_framework_response(cls, response, mimetype, extra_context=None):
  141. """ Cast ConnexionResponse to framework response class """
  142. flask_response = cls._build_response(
  143. mimetype=response.mimetype or mimetype,
  144. content_type=response.content_type,
  145. headers=response.headers,
  146. status_code=response.status_code,
  147. data=response.body,
  148. extra_context=extra_context,
  149. )
  150. return flask_response
  151. @classmethod
  152. def _build_response(cls, mimetype, content_type=None, headers=None, status_code=None, data=None, extra_context=None):
  153. if cls._is_framework_response(data):
  154. return flask.current_app.make_response((data, status_code, headers))
  155. data, status_code, serialized_mimetype = cls._prepare_body_and_status_code(data=data, mimetype=mimetype, status_code=status_code, extra_context=extra_context)
  156. kwargs = {
  157. 'mimetype': mimetype or serialized_mimetype,
  158. 'content_type': content_type,
  159. 'headers': headers,
  160. 'response': data,
  161. 'status': status_code
  162. }
  163. kwargs = {k: v for k, v in kwargs.items() if v is not None}
  164. return flask.current_app.response_class(**kwargs)
  165. @classmethod
  166. def _serialize_data(cls, data, mimetype):
  167. # TODO: harmonize flask and aiohttp serialization when mimetype=None or mimetype is not JSON
  168. # (cases where it might not make sense to jsonify the data)
  169. if (isinstance(mimetype, str) and is_json_mimetype(mimetype)):
  170. body = cls.jsonifier.dumps(data)
  171. elif not (isinstance(data, bytes) or isinstance(data, str)):
  172. warnings.warn(
  173. "Implicit (flask) JSON serialization will change in the next major version. "
  174. "This is triggered because a response body is being serialized as JSON "
  175. "even though the mimetype is not a JSON type. "
  176. "This will be replaced by something that is mimetype-specific and may "
  177. "raise an error instead of silently converting everything to JSON. "
  178. "Please make sure to specify media/mime types in your specs.",
  179. FutureWarning # a Deprecation targeted at application users.
  180. )
  181. body = cls.jsonifier.dumps(data)
  182. else:
  183. body = data
  184. return body, mimetype
  185. @classmethod
  186. def get_request(cls, *args, **params):
  187. # type: (*Any, **Any) -> ConnexionRequest
  188. """Gets ConnexionRequest instance for the operation handler
  189. result. Status Code and Headers for response. If only body
  190. data is returned by the endpoint function, then the status
  191. code will be set to 200 and no headers will be added.
  192. If the returned object is a flask.Response then it will just
  193. pass the information needed to recreate it.
  194. :rtype: ConnexionRequest
  195. """
  196. context_dict = {}
  197. setattr(flask._request_ctx_stack.top, 'connexion_context', context_dict)
  198. flask_request = flask.request
  199. request = ConnexionRequest(
  200. flask_request.url,
  201. flask_request.method,
  202. headers=flask_request.headers,
  203. form=flask_request.form,
  204. query=flask_request.args,
  205. body=flask_request.get_data(),
  206. json_getter=lambda: flask_request.get_json(silent=True),
  207. files=flask_request.files,
  208. path_params=params,
  209. context=context_dict,
  210. cookies=flask_request.cookies,
  211. )
  212. logger.debug('Getting data and status code',
  213. extra={
  214. 'data': request.body,
  215. 'data_type': type(request.body),
  216. 'url': request.url
  217. })
  218. return request
  219. @classmethod
  220. def _set_jsonifier(cls):
  221. """
  222. Use Flask specific JSON loader
  223. """
  224. cls.jsonifier = Jsonifier(flask.json, indent=2)
  225. def _get_context():
  226. return getattr(flask._request_ctx_stack.top, 'connexion_context')
  227. context = LocalProxy(_get_context)
  228. class InternalHandlers:
  229. """
  230. Flask handlers for internally registered endpoints.
  231. """
  232. def __init__(self, base_path, options, specification):
  233. self.base_path = base_path
  234. self.options = options
  235. self.specification = specification
  236. def console_ui_home(self):
  237. """
  238. Home page of the OpenAPI Console UI.
  239. :return:
  240. """
  241. openapi_json_route_name = "{blueprint}.{prefix}_openapi_json"
  242. escaped = flask_utils.flaskify_endpoint(self.base_path)
  243. openapi_json_route_name = openapi_json_route_name.format(
  244. blueprint=escaped,
  245. prefix=escaped
  246. )
  247. template_variables = {
  248. 'openapi_spec_url': flask.url_for(openapi_json_route_name),
  249. **self.options.openapi_console_ui_index_template_variables,
  250. }
  251. if self.options.openapi_console_ui_config is not None:
  252. template_variables['configUrl'] = 'swagger-ui-config.json'
  253. # Use `render_template_string` instead of `render_template` to circumvent the flask
  254. # template lookup mechanism and explicitly render the template of the current blueprint.
  255. # https://github.com/zalando/connexion/issues/1289#issuecomment-884105076
  256. template_dir = pathlib.Path(self.options.openapi_console_ui_from_dir)
  257. index_path = template_dir / 'index.j2'
  258. return flask.render_template_string(index_path.read_text(), **template_variables)
  259. def console_ui_static_files(self, filename):
  260. """
  261. Servers the static files for the OpenAPI Console UI.
  262. :param filename: Requested file contents.
  263. :return:
  264. """
  265. # convert PosixPath to str
  266. static_dir = str(self.options.openapi_console_ui_from_dir)
  267. return flask.send_from_directory(static_dir, filename)
  268. def get_json_spec(self):
  269. return flask.jsonify(self._spec_for_prefix())
  270. def get_yaml_spec(self):
  271. return yamldumper(self._spec_for_prefix()), 200, {"Content-Type": "text/yaml"}
  272. def _spec_for_prefix(self):
  273. """
  274. Modify base_path in the spec based on incoming url
  275. This fixes problems with reverse proxies changing the path.
  276. """
  277. base_path = flask.url_for(flask.request.endpoint).rsplit("/", 1)[0]
  278. return self.specification.with_base_path(base_path).raw