"""Various utilities for parsing OpenAPI operations from docstrings and validating against
the OpenAPI spec.
"""
from __future__ import annotations
import re
import json
import typing
from distutils import version
from apispec import exceptions
if typing.TYPE_CHECKING:
from apispec.core import APISpec
COMPONENT_SUBSECTIONS = {
2: {
"schema": "definitions",
"response": "responses",
"parameter": "parameters",
"security_scheme": "securityDefinitions",
},
3: {
"schema": "schemas",
"response": "responses",
"parameter": "parameters",
"header": "headers",
"example": "examples",
"security_scheme": "securitySchemes",
},
}
[docs]def build_reference(
component_type: str, openapi_major_version: int, component_name: str
) -> dict[str, str]:
"""Return path to reference
:param str component_type: Component type (schema, parameter, response, security_scheme)
:param int openapi_major_version: OpenAPI major version (2 or 3)
:param str component_name: Name of component to reference
"""
return {
"$ref": "#/{}{}/{}".format(
"components/" if openapi_major_version >= 3 else "",
COMPONENT_SUBSECTIONS[openapi_major_version][component_type],
component_name,
)
}
[docs]def validate_spec(spec: APISpec) -> bool:
"""Validate the output of an :class:`APISpec` object against the
OpenAPI specification.
Note: Requires installing apispec with the ``[validation]`` extras.
::
pip install 'apispec[validation]'
:raise: apispec.exceptions.OpenAPIError if validation fails.
"""
try:
import prance
except ImportError as error: # re-raise with a more verbose message
exc_class = type(error)
raise exc_class(
"validate_spec requires prance to be installed. "
"You can install all validation requirements using:\n"
" pip install 'apispec[validation]'"
) from error
parser_kwargs = {}
if spec.openapi_version.version[0] == 3:
parser_kwargs["backend"] = "openapi-spec-validator"
try:
prance.BaseParser(spec_string=json.dumps(spec.to_dict()), **parser_kwargs)
except prance.ValidationError as err:
raise exceptions.OpenAPIError(*err.args) from err
else:
return True
[docs]class OpenAPIVersion(version.LooseVersion):
"""OpenAPI version
:param str|OpenAPIVersion openapi_version: OpenAPI version
Parses an OpenAPI version expressed as string. Provides shortcut to digits
(major, minor, patch).
Example: ::
ver = OpenAPIVersion('3.0.2')
assert ver.major == 3
assert ver.minor == 0
assert ver.patch == 1
assert ver.vstring == '3.0.2'
assert str(ver) == '3.0.2'
"""
MIN_INCLUSIVE_VERSION = version.LooseVersion("2.0")
MAX_EXCLUSIVE_VERSION = version.LooseVersion("4.0")
def __init__(self, openapi_version: version.LooseVersion | str) -> None:
if isinstance(openapi_version, version.LooseVersion):
openapi_version = openapi_version.vstring
if (
not self.MIN_INCLUSIVE_VERSION
<= openapi_version
< self.MAX_EXCLUSIVE_VERSION
):
raise exceptions.APISpecError(
f"Not a valid OpenAPI version number: {openapi_version}"
)
super().__init__(openapi_version)
@property
def major(self) -> int:
return int(self.version[0])
@property
def minor(self) -> int:
return int(self.version[1])
@property
def patch(self) -> int:
return int(self.version[2])
# from django.contrib.admindocs.utils
[docs]def trim_docstring(docstring: str) -> str:
"""Uniformly trims leading/trailing whitespace from docstrings.
Based on http://www.python.org/peps/pep-0257.html#handling-docstring-indentation
"""
if not docstring or not docstring.strip():
return ""
# Convert tabs to spaces and split into lines
lines = docstring.expandtabs().splitlines()
indent = min(len(line) - len(line.lstrip()) for line in lines if line.lstrip())
trimmed = [lines[0].lstrip()] + [line[indent:].rstrip() for line in lines[1:]]
return "\n".join(trimmed).strip()
# from rest_framework.utils.formatting
[docs]def dedent(content: str) -> str:
"""
Remove leading indent from a block of text.
Used when generating descriptions from docstrings.
Note that python's `textwrap.dedent` doesn't quite cut it,
as it fails to dedent multiline docstrings that include
unindented text on the initial line.
"""
whitespace_counts = [
len(line) - len(line.lstrip(" "))
for line in content.splitlines()[1:]
if line.lstrip()
]
# unindent the content if needed
if whitespace_counts:
whitespace_pattern = "^" + (" " * min(whitespace_counts))
content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), "", content)
return content.strip()
# http://stackoverflow.com/a/8310229
[docs]def deepupdate(original: dict, update: dict) -> dict:
"""Recursively update a dict.
Subdict's won't be overwritten but also updated.
"""
for key, value in original.items():
if key not in update:
update[key] = value
elif isinstance(value, dict):
deepupdate(value, update[key])
return update