openapi.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. """
  2. This module defines an OpenAPIOperation class, a Connexion operation specific for OpenAPI 3 specs.
  3. """
  4. import logging
  5. import warnings
  6. from copy import copy, deepcopy
  7. from connexion.operations.abstract import AbstractOperation
  8. from ..decorators.uri_parsing import OpenAPIURIParser
  9. from ..http_facts import FORM_CONTENT_TYPES
  10. from ..utils import deep_get, deep_merge, is_null, is_nullable, make_type
  11. logger = logging.getLogger("connexion.operations.openapi3")
  12. class OpenAPIOperation(AbstractOperation):
  13. """
  14. A single API operation on a path.
  15. """
  16. def __init__(self, api, method, path, operation, resolver, path_parameters=None,
  17. app_security=None, components=None, validate_responses=False,
  18. strict_validation=False, randomize_endpoint=None, validator_map=None,
  19. pythonic_params=False, uri_parser_class=None, pass_context_arg_name=None):
  20. """
  21. This class uses the OperationID identify the module and function that will handle the operation
  22. From Swagger Specification:
  23. **OperationID**
  24. A friendly name for the operation. The id MUST be unique among all operations described in the API.
  25. Tools and libraries MAY use the operation id to uniquely identify an operation.
  26. :param method: HTTP method
  27. :type method: str
  28. :param path:
  29. :type path: str
  30. :param operation: swagger operation object
  31. :type operation: dict
  32. :param resolver: Callable that maps operationID to a function
  33. :param path_parameters: Parameters defined in the path level
  34. :type path_parameters: list
  35. :param app_security: list of security rules the application uses by default
  36. :type app_security: list
  37. :param components: `Components Object
  38. <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject>`_
  39. :type components: dict
  40. :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses.
  41. :type validate_responses: bool
  42. :param strict_validation: True enables validation on invalid request parameters
  43. :type strict_validation: bool
  44. :param randomize_endpoint: number of random characters to append to operation name
  45. :type randomize_endpoint: integer
  46. :param validator_map: Custom validators for the types "parameter", "body" and "response".
  47. :type validator_map: dict
  48. :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended
  49. to any shadowed built-ins
  50. :type pythonic_params: bool
  51. :param uri_parser_class: class to use for uri parsing
  52. :type uri_parser_class: AbstractURIParser
  53. :param pass_context_arg_name: If not None will try to inject the request context to the function using this
  54. name.
  55. :type pass_context_arg_name: str|None
  56. """
  57. self.components = components or {}
  58. def component_get(oas3_name):
  59. return self.components.get(oas3_name, {})
  60. # operation overrides globals
  61. security_schemes = component_get('securitySchemes')
  62. app_security = operation.get('security', app_security)
  63. uri_parser_class = uri_parser_class or OpenAPIURIParser
  64. self._router_controller = operation.get('x-openapi-router-controller')
  65. super().__init__(
  66. api=api,
  67. method=method,
  68. path=path,
  69. operation=operation,
  70. resolver=resolver,
  71. app_security=app_security,
  72. security_schemes=security_schemes,
  73. validate_responses=validate_responses,
  74. strict_validation=strict_validation,
  75. randomize_endpoint=randomize_endpoint,
  76. validator_map=validator_map,
  77. pythonic_params=pythonic_params,
  78. uri_parser_class=uri_parser_class,
  79. pass_context_arg_name=pass_context_arg_name
  80. )
  81. self._definitions_map = {
  82. 'components': {
  83. 'schemas': component_get('schemas'),
  84. 'examples': component_get('examples'),
  85. 'requestBodies': component_get('requestBodies'),
  86. 'parameters': component_get('parameters'),
  87. 'securitySchemes': component_get('securitySchemes'),
  88. 'responses': component_get('responses'),
  89. 'headers': component_get('headers'),
  90. }
  91. }
  92. self._request_body = operation.get('requestBody', {})
  93. self._parameters = operation.get('parameters', [])
  94. if path_parameters:
  95. self._parameters += path_parameters
  96. self._responses = operation.get('responses', {})
  97. # TODO figure out how to support multiple mimetypes
  98. # NOTE we currently just combine all of the possible mimetypes,
  99. # but we need to refactor to support mimetypes by response code
  100. response_content_types = []
  101. for _, defn in self._responses.items():
  102. response_content_types += defn.get('content', {}).keys()
  103. self._produces = response_content_types or ['application/json']
  104. request_content = self._request_body.get('content', {})
  105. self._consumes = list(request_content.keys()) or ['application/json']
  106. logger.debug('consumes: %s' % self.consumes)
  107. logger.debug('produces: %s' % self.produces)
  108. @classmethod
  109. def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs):
  110. return cls(
  111. api,
  112. method,
  113. path,
  114. spec.get_operation(path, method),
  115. resolver=resolver,
  116. path_parameters=spec.get_path_params(path),
  117. app_security=spec.security,
  118. components=spec.components,
  119. *args,
  120. **kwargs
  121. )
  122. @property
  123. def request_body(self):
  124. return self._request_body
  125. @property
  126. def parameters(self):
  127. return self._parameters
  128. @property
  129. def consumes(self):
  130. return self._consumes
  131. @property
  132. def produces(self):
  133. return self._produces
  134. def with_definitions(self, schema):
  135. if self.components:
  136. schema['schema']['components'] = self.components
  137. return schema
  138. def response_schema(self, status_code=None, content_type=None):
  139. response_definition = self.response_definition(
  140. status_code, content_type
  141. )
  142. content_definition = response_definition.get("content", response_definition)
  143. content_definition = content_definition.get(content_type, content_definition)
  144. if "schema" in content_definition:
  145. return self.with_definitions(content_definition).get("schema", {})
  146. return {}
  147. def example_response(self, status_code=None, content_type=None):
  148. """
  149. Returns example response from spec
  150. """
  151. # simply use the first/lowest status code, this is probably 200 or 201
  152. status_code = status_code or sorted(self._responses.keys())[0]
  153. content_type = content_type or self.get_mimetype()
  154. examples_path = [str(status_code), 'content', content_type, 'examples']
  155. example_path = [str(status_code), 'content', content_type, 'example']
  156. schema_example_path = [
  157. str(status_code), 'content', content_type, 'schema', 'example'
  158. ]
  159. schema_path = [str(status_code), 'content', content_type, 'schema']
  160. try:
  161. status_code = int(status_code)
  162. except ValueError:
  163. status_code = 200
  164. try:
  165. # TODO also use example header?
  166. return (
  167. list(deep_get(self._responses, examples_path).values())[0]['value'],
  168. status_code
  169. )
  170. except (KeyError, IndexError):
  171. pass
  172. try:
  173. return (deep_get(self._responses, example_path), status_code)
  174. except KeyError:
  175. pass
  176. try:
  177. return (deep_get(self._responses, schema_example_path),
  178. status_code)
  179. except KeyError:
  180. pass
  181. try:
  182. return (self._nested_example(deep_get(self._responses, schema_path)),
  183. status_code)
  184. except KeyError:
  185. return (None, status_code)
  186. def _nested_example(self, schema):
  187. try:
  188. return schema["example"]
  189. except KeyError:
  190. pass
  191. try:
  192. # Recurse if schema is an object
  193. return {key: self._nested_example(value)
  194. for (key, value) in schema["properties"].items()}
  195. except KeyError:
  196. pass
  197. try:
  198. # Recurse if schema is an array
  199. return [self._nested_example(schema["items"])]
  200. except KeyError:
  201. raise
  202. def get_path_parameter_types(self):
  203. types = {}
  204. path_parameters = (p for p in self.parameters if p["in"] == "path")
  205. for path_defn in path_parameters:
  206. path_schema = path_defn["schema"]
  207. if path_schema.get('type') == 'string' and path_schema.get('format') == 'path':
  208. # path is special case for type 'string'
  209. path_type = 'path'
  210. else:
  211. path_type = path_schema.get('type')
  212. types[path_defn['name']] = path_type
  213. return types
  214. @property
  215. def body_schema(self):
  216. """
  217. The body schema definition for this operation.
  218. """
  219. return self.body_definition.get('schema', {})
  220. @property
  221. def body_definition(self):
  222. """
  223. The body complete definition for this operation.
  224. **There can be one "body" parameter at most.**
  225. :rtype: dict
  226. """
  227. if self._request_body:
  228. if len(self.consumes) > 1:
  229. logger.warning(
  230. 'this operation accepts multiple content types, using %s',
  231. self.consumes[0])
  232. res = self._request_body.get('content', {}).get(self.consumes[0], {})
  233. return self.with_definitions(res)
  234. return {}
  235. def _get_body_argument(self, body, arguments, has_kwargs, sanitize):
  236. if len(arguments) <= 0 and not has_kwargs:
  237. return {}
  238. # get the deprecated name from the body-schema for legacy connexion compat
  239. x_body_name = sanitize(self.body_schema.get('x-body-name'))
  240. if x_body_name:
  241. warnings.warn('x-body-name within the requestBody schema will be deprecated in the '
  242. 'next major version. It should be provided directly under '
  243. 'the requestBody instead.', DeprecationWarning)
  244. # prefer the x-body-name as an extension of requestBody, fallback to deprecated schema name, default 'body'
  245. x_body_name = sanitize(self.request_body.get('x-body-name', x_body_name or 'body'))
  246. if self.consumes[0] in FORM_CONTENT_TYPES:
  247. result = self._get_body_argument_form(body)
  248. else:
  249. result = self._get_body_argument_json(body)
  250. if x_body_name in arguments or has_kwargs:
  251. return {x_body_name: result}
  252. return {}
  253. def _get_body_argument_json(self, body):
  254. # if the body came in null, and the schema says it can be null, we decide
  255. # to include no value for the body argument, rather than the default body
  256. if is_nullable(self.body_schema) and is_null(body):
  257. return None
  258. if body is None:
  259. default_body = self.body_schema.get('default', {})
  260. return deepcopy(default_body)
  261. return body
  262. def _get_body_argument_form(self, body):
  263. # now determine the actual value for the body (whether it came in or is default)
  264. default_body = self.body_schema.get('default', {})
  265. body_props = {k: {"schema": v} for k, v
  266. in self.body_schema.get("properties", {}).items()}
  267. # by OpenAPI specification `additionalProperties` defaults to `true`
  268. # see: https://github.com/OAI/OpenAPI-Specification/blame/3.0.2/versions/3.0.2.md#L2305
  269. additional_props = self.body_schema.get("additionalProperties", True)
  270. body_arg = deepcopy(default_body)
  271. body_arg.update(body or {})
  272. if body_props or additional_props:
  273. return self._get_typed_body_values(body_arg, body_props, additional_props)
  274. return {}
  275. def _get_typed_body_values(self, body_arg, body_props, additional_props):
  276. """
  277. Return a copy of the provided body_arg dictionary
  278. whose values will have the appropriate types
  279. as defined in the provided schemas.
  280. :type body_arg: type dict
  281. :type body_props: dict
  282. :type additional_props: dict|bool
  283. :rtype: dict
  284. """
  285. additional_props_defn = {"schema": additional_props} if isinstance(additional_props, dict) else None
  286. res = {}
  287. for key, value in body_arg.items():
  288. try:
  289. prop_defn = body_props[key]
  290. res[key] = self._get_val_from_param(value, prop_defn)
  291. except KeyError: # pragma: no cover
  292. if not additional_props:
  293. logger.error(f"Body property '{key}' not defined in body schema")
  294. continue
  295. if additional_props_defn is not None:
  296. value = self._get_val_from_param(value, additional_props_defn)
  297. res[key] = value
  298. return res
  299. def _build_default_obj_recursive(self, _properties, res):
  300. """ takes disparate and nested default keys, and builds up a default object
  301. """
  302. for key, prop in _properties.items():
  303. if 'default' in prop and key not in res:
  304. res[key] = copy(prop['default'])
  305. elif prop.get('type') == 'object' and 'properties' in prop:
  306. res.setdefault(key, {})
  307. res[key] = self._build_default_obj_recursive(prop['properties'], res[key])
  308. return res
  309. def _get_default_obj(self, schema):
  310. try:
  311. return deepcopy(schema["default"])
  312. except KeyError:
  313. _properties = schema.get("properties", {})
  314. return self._build_default_obj_recursive(_properties, {})
  315. def _get_query_defaults(self, query_defns):
  316. defaults = {}
  317. for k, v in query_defns.items():
  318. try:
  319. if v["schema"]["type"] == "object":
  320. defaults[k] = self._get_default_obj(v["schema"])
  321. else:
  322. defaults[k] = v["schema"]["default"]
  323. except KeyError:
  324. pass
  325. return defaults
  326. def _get_query_arguments(self, query, arguments, has_kwargs, sanitize):
  327. query_defns = {p["name"]: p
  328. for p in self.parameters
  329. if p["in"] == "query"}
  330. default_query_params = self._get_query_defaults(query_defns)
  331. query_arguments = deepcopy(default_query_params)
  332. query_arguments = deep_merge(query_arguments, query)
  333. return self._query_args_helper(query_defns, query_arguments,
  334. arguments, has_kwargs, sanitize)
  335. def _get_val_from_param(self, value, query_defn):
  336. query_schema = query_defn["schema"]
  337. if is_nullable(query_schema) and is_null(value):
  338. return None
  339. if query_schema["type"] == "array":
  340. return [make_type(part, query_schema["items"]["type"]) for part in value]
  341. else:
  342. return make_type(value, query_schema["type"])