core.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. """Core apispec classes and functions."""
  2. from __future__ import annotations
  3. import typing
  4. import warnings
  5. from collections.abc import Sequence
  6. from copy import deepcopy
  7. from packaging.version import Version
  8. from .exceptions import (
  9. APISpecError,
  10. DuplicateComponentNameError,
  11. DuplicateParameterError,
  12. InvalidParameterError,
  13. PluginMethodNotImplementedError,
  14. )
  15. from .utils import COMPONENT_SUBSECTIONS, build_reference, deepupdate
  16. if typing.TYPE_CHECKING:
  17. from .plugin import BasePlugin
  18. VALID_METHODS_OPENAPI_V2 = ["get", "post", "put", "patch", "delete", "head", "options"]
  19. VALID_METHODS_OPENAPI_V3 = VALID_METHODS_OPENAPI_V2 + ["trace"]
  20. VALID_METHODS = {2: VALID_METHODS_OPENAPI_V2, 3: VALID_METHODS_OPENAPI_V3}
  21. MIN_INCLUSIVE_OPENAPI_VERSION = Version("2.0")
  22. MAX_EXCLUSIVE_OPENAPI_VERSION = Version("4.0")
  23. class Components:
  24. """Stores OpenAPI components
  25. Components are top-level fields in OAS v2.
  26. They became sub-fields of "components" top-level field in OAS v3.
  27. """
  28. def __init__(
  29. self,
  30. plugins: Sequence[BasePlugin],
  31. openapi_version: Version,
  32. ) -> None:
  33. self._plugins = plugins
  34. self.openapi_version = openapi_version
  35. self.schemas: dict[str, dict] = {}
  36. self.responses: dict[str, dict] = {}
  37. self.parameters: dict[str, dict] = {}
  38. self.headers: dict[str, dict] = {}
  39. self.examples: dict[str, dict] = {}
  40. self.security_schemes: dict[str, dict] = {}
  41. self.schemas_lazy: dict[str, dict] = {}
  42. self.responses_lazy: dict[str, dict] = {}
  43. self.parameters_lazy: dict[str, dict] = {}
  44. self.headers_lazy: dict[str, dict] = {}
  45. self.examples_lazy: dict[str, dict] = {}
  46. self._subsections = {
  47. "schema": self.schemas,
  48. "response": self.responses,
  49. "parameter": self.parameters,
  50. "header": self.headers,
  51. "example": self.examples,
  52. "security_scheme": self.security_schemes,
  53. }
  54. self._subsections_lazy = {
  55. "schema": self.schemas_lazy,
  56. "response": self.responses_lazy,
  57. "parameter": self.parameters_lazy,
  58. "header": self.headers_lazy,
  59. "example": self.examples_lazy,
  60. }
  61. def to_dict(self) -> dict[str, dict]:
  62. return {
  63. COMPONENT_SUBSECTIONS[self.openapi_version.major][k]: v
  64. for k, v in self._subsections.items()
  65. if v != {}
  66. }
  67. def _register_component(
  68. self,
  69. obj_type: str,
  70. component_id: str,
  71. component: dict,
  72. *,
  73. lazy: bool = False,
  74. ) -> None:
  75. subsection = (self._subsections if lazy is False else self._subsections_lazy)[
  76. obj_type
  77. ]
  78. subsection[component_id] = component
  79. def _do_register_lazy_component(
  80. self,
  81. obj_type: str,
  82. component_id: str,
  83. ) -> None:
  84. component_buffer = self._subsections_lazy[obj_type]
  85. # If component was lazy registered, register it for real
  86. if component_id in component_buffer:
  87. self._subsections[obj_type][component_id] = component_buffer.pop(
  88. component_id
  89. )
  90. def get_ref(
  91. self,
  92. obj_type: str,
  93. obj_or_component_id: dict | str,
  94. ) -> dict:
  95. """Return object or reference
  96. If obj is a dict, it is assumed to be a complete description and it is returned as is.
  97. Otherwise, it is assumed to be a reference name as string and the corresponding $ref
  98. string is returned.
  99. :param str subsection: "schema", "parameter", "response" or "security_scheme"
  100. :param dict|str obj: object in dict form or as ref_id string
  101. """
  102. if isinstance(obj_or_component_id, dict):
  103. return obj_or_component_id
  104. # Register the component if it was lazy registered
  105. self._do_register_lazy_component(obj_type, obj_or_component_id)
  106. return build_reference(
  107. obj_type, self.openapi_version.major, obj_or_component_id
  108. )
  109. def schema(
  110. self,
  111. component_id: str,
  112. component: dict | None = None,
  113. *,
  114. lazy: bool = False,
  115. **kwargs: typing.Any,
  116. ) -> Components:
  117. """Add a new schema to the spec.
  118. :param str component_id: identifier by which schema may be referenced
  119. :param dict component: schema definition
  120. :param bool lazy: register component only when referenced in the spec
  121. :param kwargs: plugin-specific arguments
  122. .. note::
  123. If you are using `apispec.ext.marshmallow`, you can pass fields' metadata as
  124. additional keyword arguments.
  125. For example, to add ``enum`` and ``description`` to your field: ::
  126. status = fields.String(
  127. required=True,
  128. metadata={
  129. "description": "Status (open or closed)",
  130. "enum": ["open", "closed"],
  131. },
  132. )
  133. https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject
  134. """
  135. if component_id in self.schemas:
  136. raise DuplicateComponentNameError(
  137. f'Another schema with name "{component_id}" is already registered.'
  138. )
  139. ret = deepcopy(component) or {}
  140. # Execute all helpers from plugins
  141. for plugin in self._plugins:
  142. try:
  143. ret.update(plugin.schema_helper(component_id, ret, **kwargs) or {})
  144. except PluginMethodNotImplementedError:
  145. continue
  146. self._resolve_refs_in_schema(ret)
  147. self._register_component("schema", component_id, ret, lazy=lazy)
  148. return self
  149. def response(
  150. self,
  151. component_id: str,
  152. component: dict | None = None,
  153. *,
  154. lazy: bool = False,
  155. **kwargs: typing.Any,
  156. ) -> Components:
  157. """Add a response which can be referenced.
  158. :param str component_id: ref_id to use as reference
  159. :param dict component: response fields
  160. :param bool lazy: register component only when referenced in the spec
  161. :param kwargs: plugin-specific arguments
  162. """
  163. if component_id in self.responses:
  164. raise DuplicateComponentNameError(
  165. f'Another response with name "{component_id}" is already registered.'
  166. )
  167. ret = deepcopy(component) or {}
  168. # Execute all helpers from plugins
  169. for plugin in self._plugins:
  170. try:
  171. ret.update(plugin.response_helper(ret, **kwargs) or {})
  172. except PluginMethodNotImplementedError:
  173. continue
  174. self._resolve_refs_in_response(ret)
  175. self._register_component("response", component_id, ret, lazy=lazy)
  176. return self
  177. def parameter(
  178. self,
  179. component_id: str,
  180. location: str,
  181. component: dict | None = None,
  182. *,
  183. lazy: bool = False,
  184. **kwargs: typing.Any,
  185. ) -> Components:
  186. """Add a parameter which can be referenced.
  187. :param str component_id: identifier by which parameter may be referenced
  188. :param str location: location of the parameter
  189. :param dict component: parameter fields
  190. :param bool lazy: register component only when referenced in the spec
  191. :param kwargs: plugin-specific arguments
  192. """
  193. if component_id in self.parameters:
  194. raise DuplicateComponentNameError(
  195. f'Another parameter with name "{component_id}" is already registered.'
  196. )
  197. ret = deepcopy(component) or {}
  198. ret.setdefault("name", component_id)
  199. ret["in"] = location
  200. # if "in" is set to "path", enforce required flag to True
  201. if location == "path":
  202. ret["required"] = True
  203. # Execute all helpers from plugins
  204. for plugin in self._plugins:
  205. try:
  206. ret.update(plugin.parameter_helper(ret, **kwargs) or {})
  207. except PluginMethodNotImplementedError:
  208. continue
  209. self._resolve_refs_in_parameter_or_header(ret)
  210. self._register_component("parameter", component_id, ret, lazy=lazy)
  211. return self
  212. def header(
  213. self,
  214. component_id: str,
  215. component: dict,
  216. *,
  217. lazy: bool = False,
  218. **kwargs: typing.Any,
  219. ) -> Components:
  220. """Add a header which can be referenced.
  221. :param str component_id: identifier by which header may be referenced
  222. :param dict component: header fields
  223. :param bool lazy: register component only when referenced in the spec
  224. :param kwargs: plugin-specific arguments
  225. https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#headerObject
  226. """
  227. ret = deepcopy(component) or {}
  228. if component_id in self.headers:
  229. raise DuplicateComponentNameError(
  230. f'Another header with name "{component_id}" is already registered.'
  231. )
  232. # Execute all helpers from plugins
  233. for plugin in self._plugins:
  234. try:
  235. ret.update(plugin.header_helper(ret, **kwargs) or {})
  236. except PluginMethodNotImplementedError:
  237. continue
  238. self._resolve_refs_in_parameter_or_header(ret)
  239. self._register_component("header", component_id, ret, lazy=lazy)
  240. return self
  241. def example(
  242. self, component_id: str, component: dict, *, lazy: bool = False
  243. ) -> Components:
  244. """Add an example which can be referenced
  245. :param str component_id: identifier by which example may be referenced
  246. :param dict component: example fields
  247. :param bool lazy: register component only when referenced in the spec
  248. https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject
  249. """
  250. if component_id in self.examples:
  251. raise DuplicateComponentNameError(
  252. f'Another example with name "{component_id}" is already registered.'
  253. )
  254. self._register_component("example", component_id, component, lazy=lazy)
  255. return self
  256. def security_scheme(self, component_id: str, component: dict) -> Components:
  257. """Add a security scheme which can be referenced.
  258. :param str component_id: component_id to use as reference
  259. :param dict component: security scheme fields
  260. """
  261. if component_id in self.security_schemes:
  262. raise DuplicateComponentNameError(
  263. f'Another security scheme with name "{component_id}" is already registered.'
  264. )
  265. self._register_component("security_scheme", component_id, component)
  266. return self
  267. def _resolve_schema(self, obj) -> None:
  268. """Replace schema reference as string with a $ref if needed
  269. Also resolve references in the schema
  270. """
  271. if "schema" in obj:
  272. obj["schema"] = self.get_ref("schema", obj["schema"])
  273. self._resolve_refs_in_schema(obj["schema"])
  274. def _resolve_examples(self, obj) -> None:
  275. """Replace example reference as string with a $ref"""
  276. for name, example in obj.get("examples", {}).items():
  277. obj["examples"][name] = self.get_ref("example", example)
  278. def _resolve_refs_in_schema(self, schema: dict) -> None:
  279. if "properties" in schema:
  280. for key in schema["properties"]:
  281. schema["properties"][key] = self.get_ref(
  282. "schema", schema["properties"][key]
  283. )
  284. self._resolve_refs_in_schema(schema["properties"][key])
  285. if "items" in schema:
  286. schema["items"] = self.get_ref("schema", schema["items"])
  287. self._resolve_refs_in_schema(schema["items"])
  288. for key in ("allOf", "oneOf", "anyOf"):
  289. if key in schema:
  290. schema[key] = [self.get_ref("schema", s) for s in schema[key]]
  291. for sch in schema[key]:
  292. self._resolve_refs_in_schema(sch)
  293. if "not" in schema:
  294. schema["not"] = self.get_ref("schema", schema["not"])
  295. self._resolve_refs_in_schema(schema["not"])
  296. def _resolve_refs_in_parameter_or_header(self, parameter_or_header) -> None:
  297. self._resolve_schema(parameter_or_header)
  298. self._resolve_examples(parameter_or_header)
  299. # parameter content is OpenAPI v3+
  300. for media_type in parameter_or_header.get("content", {}).values():
  301. self._resolve_schema(media_type)
  302. def _resolve_refs_in_request_body(self, request_body) -> None:
  303. # requestBody is OpenAPI v3+
  304. for media_type in request_body["content"].values():
  305. self._resolve_schema(media_type)
  306. self._resolve_examples(media_type)
  307. def _resolve_refs_in_response(self, response) -> None:
  308. if self.openapi_version.major < 3:
  309. self._resolve_schema(response)
  310. else:
  311. for media_type in response.get("content", {}).values():
  312. self._resolve_schema(media_type)
  313. self._resolve_examples(media_type)
  314. for name, header in response.get("headers", {}).items():
  315. response["headers"][name] = self.get_ref("header", header)
  316. self._resolve_refs_in_parameter_or_header(response["headers"][name])
  317. # TODO: Resolve link refs when Components supports links
  318. def _resolve_refs_in_operation(self, operation) -> None:
  319. if "parameters" in operation:
  320. parameters = []
  321. for parameter in operation["parameters"]:
  322. parameter = self.get_ref("parameter", parameter)
  323. self._resolve_refs_in_parameter_or_header(parameter)
  324. parameters.append(parameter)
  325. operation["parameters"] = parameters
  326. if "callbacks" in operation:
  327. for callback in operation["callbacks"].values():
  328. if isinstance(callback, dict):
  329. for path in callback.values():
  330. self.resolve_refs_in_path(path)
  331. if "requestBody" in operation:
  332. self._resolve_refs_in_request_body(operation["requestBody"])
  333. if "responses" in operation:
  334. responses = {}
  335. for code, response in operation["responses"].items():
  336. response = self.get_ref("response", response)
  337. self._resolve_refs_in_response(response)
  338. responses[code] = response
  339. operation["responses"] = responses
  340. def resolve_refs_in_path(self, path) -> None:
  341. if "parameters" in path:
  342. parameters = []
  343. for parameter in path["parameters"]:
  344. parameter = self.get_ref("parameter", parameter)
  345. self._resolve_refs_in_parameter_or_header(parameter)
  346. parameters.append(parameter)
  347. path["parameters"] = parameters
  348. for method in (
  349. "get",
  350. "put",
  351. "post",
  352. "delete",
  353. "options",
  354. "head",
  355. "patch",
  356. "trace",
  357. ):
  358. if method in path:
  359. self._resolve_refs_in_operation(path[method])
  360. class APISpec:
  361. """Stores metadata that describes a RESTful API using the OpenAPI specification.
  362. :param str title: API title
  363. :param str version: API version
  364. :param list|tuple plugins: Plugin instances.
  365. See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#infoObject
  366. :param str openapi_version: OpenAPI Specification version.
  367. Should be in the form '2.x' or '3.x.x' to comply with the OpenAPI standard.
  368. :param options: Optional top-level keys
  369. See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#openapi-object
  370. """
  371. def __init__(
  372. self,
  373. title: str,
  374. version: str,
  375. openapi_version: str,
  376. plugins: Sequence[BasePlugin] = (),
  377. **options: typing.Any,
  378. ) -> None:
  379. self.title = title
  380. self.version = version
  381. self.options = options
  382. self.plugins = plugins
  383. self.openapi_version = Version(openapi_version)
  384. if not (
  385. MIN_INCLUSIVE_OPENAPI_VERSION
  386. <= self.openapi_version
  387. < MAX_EXCLUSIVE_OPENAPI_VERSION
  388. ):
  389. raise APISpecError(f"Not a valid OpenAPI version number: {openapi_version}")
  390. # Metadata
  391. self._tags: list[dict] = []
  392. self._paths: dict = {}
  393. # Components
  394. self.components = Components(self.plugins, self.openapi_version)
  395. # Plugins
  396. for plugin in self.plugins:
  397. plugin.init_spec(self)
  398. def to_dict(self) -> dict[str, typing.Any]:
  399. ret: dict[str, typing.Any] = {
  400. "paths": self._paths,
  401. "info": {"title": self.title, "version": self.version},
  402. }
  403. if self._tags:
  404. ret["tags"] = self._tags
  405. if self.openapi_version.major < 3:
  406. ret["swagger"] = str(self.openapi_version)
  407. ret.update(self.components.to_dict())
  408. else:
  409. ret["openapi"] = str(self.openapi_version)
  410. components_dict = self.components.to_dict()
  411. if components_dict:
  412. ret["components"] = components_dict
  413. ret = deepupdate(ret, self.options)
  414. return ret
  415. def to_yaml(self, yaml_dump_kwargs: typing.Any | None = None) -> str:
  416. """Render the spec to YAML. Requires PyYAML to be installed.
  417. :param dict yaml_dump_kwargs: Additional keyword arguments to pass to `yaml.dump`
  418. """
  419. from .yaml_utils import dict_to_yaml
  420. return dict_to_yaml(self.to_dict(), yaml_dump_kwargs)
  421. def tag(self, tag: dict) -> APISpec:
  422. """Store information about a tag.
  423. :param dict tag: the dictionary storing information about the tag.
  424. """
  425. self._tags.append(tag)
  426. return self
  427. def path(
  428. self,
  429. path: str | None = None,
  430. *,
  431. operations: dict[str, typing.Any] | None = None,
  432. summary: str | None = None,
  433. description: str | None = None,
  434. parameters: list[dict] | None = None,
  435. **kwargs: typing.Any,
  436. ) -> APISpec:
  437. """Add a new path object to the spec.
  438. https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#path-item-object
  439. :param str|None path: URL path component
  440. :param dict|None operations: describes the http methods and options for `path`
  441. :param str summary: short summary relevant to all operations in this path
  442. :param str description: long description relevant to all operations in this path
  443. :param list|None parameters: list of parameters relevant to all operations in this path
  444. :param kwargs: parameters used by any path helpers see :meth:`register_path_helper`
  445. """
  446. # operations and parameters must be deepcopied because they are mutated
  447. # in _clean_operations and operation helpers and path may be called twice
  448. operations = deepcopy(operations) or {}
  449. parameters = deepcopy(parameters) or []
  450. # Execute path helpers
  451. for plugin in self.plugins:
  452. try:
  453. ret = plugin.path_helper(
  454. path=path, operations=operations, parameters=parameters, **kwargs
  455. )
  456. except PluginMethodNotImplementedError:
  457. continue
  458. if ret is not None:
  459. path = ret
  460. if not path:
  461. raise APISpecError("Path template is not specified.")
  462. # Execute operation helpers
  463. for plugin in self.plugins:
  464. try:
  465. plugin.operation_helper(path=path, operations=operations, **kwargs)
  466. except PluginMethodNotImplementedError:
  467. continue
  468. self._clean_operations(operations)
  469. self._paths.setdefault(path, operations).update(operations)
  470. if summary is not None:
  471. self._paths[path]["summary"] = summary
  472. if description is not None:
  473. self._paths[path]["description"] = description
  474. if parameters:
  475. parameters = self._clean_parameters(parameters)
  476. self._paths[path]["parameters"] = parameters
  477. self.components.resolve_refs_in_path(self._paths[path])
  478. return self
  479. def _clean_parameters(
  480. self,
  481. parameters: list[dict],
  482. ) -> list[dict]:
  483. """Ensure that all parameters with "in" equal to "path" are also required
  484. as required by the OpenAPI specification, as well as normalizing any
  485. references to global parameters and checking for duplicates parameters
  486. See https ://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject.
  487. :param list parameters: List of parameters mapping
  488. """
  489. seen = set()
  490. for parameter in [p for p in parameters if isinstance(p, dict)]:
  491. # check missing name / location
  492. missing_attrs = [attr for attr in ("name", "in") if attr not in parameter]
  493. if missing_attrs:
  494. raise InvalidParameterError(
  495. f"Missing keys {missing_attrs} for parameter"
  496. )
  497. # OpenAPI Spec 3 and 2 don't allow for duplicated parameters
  498. # A unique parameter is defined by a combination of a name and location
  499. unique_key = (parameter["name"], parameter["in"])
  500. if unique_key in seen:
  501. raise DuplicateParameterError(
  502. "Duplicate parameter with name {} and location {}".format(
  503. parameter["name"], parameter["in"]
  504. )
  505. )
  506. seen.add(unique_key)
  507. # Add "required" attribute to path parameters
  508. if parameter["in"] == "path":
  509. parameter["required"] = True
  510. return parameters
  511. def _clean_operations(
  512. self,
  513. operations: dict[str, dict],
  514. ) -> None:
  515. """Ensure that all parameters with "in" equal to "path" are also required
  516. as required by the OpenAPI specification, as well as normalizing any
  517. references to global parameters. Also checks for invalid HTTP methods.
  518. See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject.
  519. :param dict operations: Dict mapping status codes to operations
  520. """
  521. operation_names = set(operations)
  522. valid_methods = set(VALID_METHODS[self.openapi_version.major])
  523. invalid = {
  524. key for key in operation_names - valid_methods if not key.startswith("x-")
  525. }
  526. if invalid:
  527. raise APISpecError(
  528. "One or more HTTP methods are invalid: {}".format(", ".join(invalid))
  529. )
  530. for operation in (operations or {}).values():
  531. if "parameters" in operation:
  532. operation["parameters"] = self._clean_parameters(
  533. operation["parameters"]
  534. )
  535. if "responses" in operation:
  536. responses = {}
  537. for code, response in operation["responses"].items():
  538. try:
  539. code = int(code) # handles IntEnums like http.HTTPStatus
  540. except (TypeError, ValueError):
  541. if self.openapi_version.major < 3 and code != "default":
  542. warnings.warn(
  543. "Non-integer code not allowed in OpenAPI < 3",
  544. UserWarning,
  545. stacklevel=2,
  546. )
  547. responses[str(code)] = response
  548. operation["responses"] = responses