2020-09-15 00:24:01 +02:00
|
|
|
# Zulip's OpenAPI-based API documentation system is documented at
|
|
|
|
# https://zulip.readthedocs.io/en/latest/documentation/api.html
|
|
|
|
#
|
|
|
|
# This file contains helper functions to interact with the OpenAPI
|
|
|
|
# definitions and validate that Zulip's implementation matches what is
|
|
|
|
# described in our documentation.
|
|
|
|
|
2021-06-07 22:14:34 +02:00
|
|
|
import json
|
2018-05-15 19:28:42 +02:00
|
|
|
import os
|
2020-06-13 17:59:46 +02:00
|
|
|
import re
|
2024-07-12 02:30:25 +02:00
|
|
|
from collections.abc import Mapping
|
|
|
|
from typing import Any, Literal
|
2018-05-15 19:28:42 +02:00
|
|
|
|
2022-01-12 03:08:52 +01:00
|
|
|
import orjson
|
2023-11-14 01:34:55 +01:00
|
|
|
from openapi_core import OpenAPI
|
2023-08-10 01:43:12 +02:00
|
|
|
from openapi_core.protocols import Request, Response
|
2022-01-12 03:08:52 +01:00
|
|
|
from openapi_core.testing import MockRequest, MockResponse
|
2023-04-04 01:42:32 +02:00
|
|
|
from openapi_core.validation.exceptions import ValidationError as OpenAPIValidationError
|
2024-02-01 05:43:43 +01:00
|
|
|
from pydantic import BaseModel
|
2020-07-01 19:07:31 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
OPENAPI_SPEC_PATH = os.path.abspath(
|
2021-02-12 08:20:45 +01:00
|
|
|
os.path.join(os.path.dirname(__file__), "../openapi/zulip.yaml")
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2018-05-15 19:28:42 +02:00
|
|
|
|
2020-06-13 17:59:46 +02:00
|
|
|
# A list of endpoint-methods such that the endpoint
|
|
|
|
# has documentation but not with this particular method.
|
2020-08-12 04:54:48 +02:00
|
|
|
EXCLUDE_UNDOCUMENTED_ENDPOINTS = {
|
|
|
|
("/users", "patch"),
|
|
|
|
}
|
2020-07-01 19:07:31 +02:00
|
|
|
# Consists of endpoints with some documentation remaining.
|
|
|
|
# These are skipped but return true as the validator cannot exclude objects
|
2024-07-12 02:30:17 +02:00
|
|
|
EXCLUDE_DOCUMENTED_ENDPOINTS: set[tuple[str, str]] = set()
|
2020-08-12 04:54:48 +02:00
|
|
|
|
2023-02-02 04:35:24 +01:00
|
|
|
|
2020-08-12 01:35:02 +02:00
|
|
|
# Most of our code expects allOf to be preprocessed away because that is what
|
|
|
|
# yamole did. Its algorithm for doing so is not standards compliant, but we
|
|
|
|
# replicate it here.
|
2024-07-12 02:30:17 +02:00
|
|
|
def naively_merge(a: dict[str, object], b: dict[str, object]) -> dict[str, object]:
|
|
|
|
ret: dict[str, object] = a.copy()
|
2020-08-12 01:35:02 +02:00
|
|
|
for key, b_value in b.items():
|
|
|
|
if key == "example" or key not in ret:
|
|
|
|
ret[key] = b_value
|
|
|
|
continue
|
|
|
|
a_value = ret[key]
|
|
|
|
if isinstance(b_value, list):
|
|
|
|
assert isinstance(a_value, list)
|
|
|
|
ret[key] = a_value + b_value
|
|
|
|
elif isinstance(b_value, dict):
|
|
|
|
assert isinstance(a_value, dict)
|
|
|
|
ret[key] = naively_merge(a_value, b_value)
|
|
|
|
return ret
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-08-12 01:35:02 +02:00
|
|
|
def naively_merge_allOf(obj: object) -> object:
|
|
|
|
if isinstance(obj, dict):
|
|
|
|
return naively_merge_allOf_dict(obj)
|
|
|
|
elif isinstance(obj, list):
|
|
|
|
return list(map(naively_merge_allOf, obj))
|
|
|
|
else:
|
|
|
|
return obj
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def naively_merge_allOf_dict(obj: dict[str, object]) -> dict[str, object]:
|
2020-08-12 01:35:02 +02:00
|
|
|
if "allOf" in obj:
|
|
|
|
ret = obj.copy()
|
|
|
|
subschemas = ret.pop("allOf")
|
|
|
|
ret = naively_merge_allOf_dict(ret)
|
|
|
|
assert isinstance(subschemas, list)
|
|
|
|
for subschema in subschemas:
|
|
|
|
assert isinstance(subschema, dict)
|
|
|
|
ret = naively_merge(ret, naively_merge_allOf_dict(subschema))
|
|
|
|
return ret
|
|
|
|
return {key: naively_merge_allOf(value) for key, value in obj.items()}
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
|
|
|
class OpenAPISpec:
|
2020-08-12 04:54:48 +02:00
|
|
|
def __init__(self, openapi_path: str) -> None:
|
|
|
|
self.openapi_path = openapi_path
|
2024-07-12 02:30:23 +02:00
|
|
|
self.mtime: float | None = None
|
2024-07-12 02:30:17 +02:00
|
|
|
self._openapi: dict[str, Any] = {}
|
|
|
|
self._endpoints_dict: dict[str, str] = {}
|
2024-07-12 02:30:23 +02:00
|
|
|
self._spec: OpenAPI | None = None
|
2018-08-07 23:40:07 +02:00
|
|
|
|
2020-08-12 03:29:51 +02:00
|
|
|
def check_reload(self) -> None:
|
2020-08-12 01:35:02 +02:00
|
|
|
# Because importing yaml takes significant time, and we only
|
|
|
|
# use python-yaml for our API docs, importing it lazily here
|
|
|
|
# is a significant optimization to `manage.py` startup.
|
2018-09-07 01:30:19 +02:00
|
|
|
#
|
|
|
|
# There is a bit of a race here...we may have two processes
|
|
|
|
# accessing this module level object and both trying to
|
|
|
|
# populate self.data at the same time. Hopefully this will
|
|
|
|
# only cause some extra processing at startup and not data
|
|
|
|
# corruption.
|
2020-08-12 03:29:51 +02:00
|
|
|
|
2020-08-12 01:35:02 +02:00
|
|
|
import yaml
|
|
|
|
from jsonref import JsonRef
|
2020-08-12 03:29:51 +02:00
|
|
|
|
2020-08-12 04:54:48 +02:00
|
|
|
with open(self.openapi_path) as f:
|
2020-08-12 01:35:02 +02:00
|
|
|
mtime = os.fstat(f.fileno()).st_mtime
|
|
|
|
# Using == rather than >= to cover the corner case of users placing an
|
|
|
|
# earlier version than the current one
|
|
|
|
if self.mtime == mtime:
|
|
|
|
return
|
|
|
|
|
|
|
|
openapi = yaml.load(f, Loader=yaml.CSafeLoader)
|
|
|
|
|
2023-11-14 01:34:55 +01:00
|
|
|
spec = OpenAPI.from_dict(openapi)
|
2022-10-06 09:57:41 +02:00
|
|
|
self._spec = spec
|
2020-08-12 01:35:02 +02:00
|
|
|
self._openapi = naively_merge_allOf_dict(JsonRef.replace_refs(openapi))
|
2020-08-12 04:54:48 +02:00
|
|
|
self.create_endpoints_dict()
|
|
|
|
self.mtime = mtime
|
2018-08-07 23:40:07 +02:00
|
|
|
|
2020-08-12 04:54:48 +02:00
|
|
|
def create_endpoints_dict(self) -> None:
|
2020-08-11 01:47:44 +02:00
|
|
|
# Algorithm description:
|
2020-06-13 17:59:46 +02:00
|
|
|
# We have 2 types of endpoints
|
|
|
|
# 1.with path arguments 2. without path arguments
|
|
|
|
# In validate_against_openapi_schema we directly check
|
|
|
|
# if we have a without path endpoint, since it does not
|
|
|
|
# require regex. Hence they are not part of the regex dict
|
|
|
|
# and now we are left with only:
|
|
|
|
# endpoint with path arguments.
|
|
|
|
# Now for this case, the regex has been created carefully,
|
|
|
|
# numeric arguments are matched with [0-9] only and
|
|
|
|
# emails are matched with their regex. This is why there are zero
|
|
|
|
# collisions. Hence if this regex matches
|
|
|
|
# an incorrect endpoint then there is some backend problem.
|
|
|
|
# For example if we have users/{name}/presence then it will
|
|
|
|
# conflict with users/me/presence even in the backend.
|
|
|
|
# Care should be taken though that if we have special strings
|
|
|
|
# such as email they must be substituted with proper regex.
|
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
email_regex = r"([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})"
|
2020-08-12 04:54:48 +02:00
|
|
|
self._endpoints_dict = {}
|
2021-02-12 08:20:45 +01:00
|
|
|
for endpoint in self._openapi["paths"]:
|
|
|
|
if "{" not in endpoint:
|
2020-06-13 17:59:46 +02:00
|
|
|
continue
|
2021-02-12 08:20:45 +01:00
|
|
|
path_regex = "^" + endpoint + "$"
|
2020-06-13 17:59:46 +02:00
|
|
|
# Numeric arguments have id at their end
|
|
|
|
# so find such arguments and replace them with numeric
|
|
|
|
# regex
|
2021-02-12 08:20:45 +01:00
|
|
|
path_regex = re.sub(r"{[^}]*id}", r"[0-9]*", path_regex)
|
2020-06-13 17:59:46 +02:00
|
|
|
# Email arguments end with email
|
2021-02-12 08:20:45 +01:00
|
|
|
path_regex = re.sub(r"{[^}]*email}", email_regex, path_regex)
|
2020-06-13 17:59:46 +02:00
|
|
|
# All other types of arguments are supposed to be
|
|
|
|
# all-encompassing string.
|
2021-02-12 08:20:45 +01:00
|
|
|
path_regex = re.sub(r"{[^}]*}", r"[^\/]*", path_regex)
|
|
|
|
path_regex = path_regex.replace(r"/", r"\/")
|
2020-08-12 04:54:48 +02:00
|
|
|
self._endpoints_dict[path_regex] = endpoint
|
2020-06-13 17:59:46 +02:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def openapi(self) -> dict[str, Any]:
|
2018-08-07 23:40:07 +02:00
|
|
|
"""Reload the OpenAPI file if it has been modified after the last time
|
|
|
|
it was read, and then return the parsed data.
|
|
|
|
"""
|
2020-08-12 03:29:51 +02:00
|
|
|
self.check_reload()
|
2021-02-12 08:19:30 +01:00
|
|
|
assert len(self._openapi) > 0
|
2020-08-12 04:54:48 +02:00
|
|
|
return self._openapi
|
2018-06-20 19:31:24 +02:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def endpoints_dict(self) -> dict[str, str]:
|
2020-06-13 17:59:46 +02:00
|
|
|
"""Reload the OpenAPI file if it has been modified after the last time
|
|
|
|
it was read, and then return the parsed data.
|
|
|
|
"""
|
2020-08-12 03:29:51 +02:00
|
|
|
self.check_reload()
|
2021-02-12 08:19:30 +01:00
|
|
|
assert len(self._endpoints_dict) > 0
|
2020-08-12 04:54:48 +02:00
|
|
|
return self._endpoints_dict
|
2020-06-13 17:59:46 +02:00
|
|
|
|
2023-11-14 01:34:55 +01:00
|
|
|
def spec(self) -> OpenAPI:
|
2020-07-09 20:28:07 +02:00
|
|
|
"""Reload the OpenAPI file if it has been modified after the last time
|
|
|
|
it was read, and then return the openapi_core validator object. Similar
|
|
|
|
to preceding functions. Used for proper access to OpenAPI objects.
|
|
|
|
"""
|
2020-08-12 03:29:51 +02:00
|
|
|
self.check_reload()
|
2022-10-06 09:57:41 +02:00
|
|
|
assert self._spec is not None
|
|
|
|
return self._spec
|
2022-01-12 03:08:52 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-05-31 19:41:17 +02:00
|
|
|
class SchemaError(Exception):
|
|
|
|
pass
|
2018-05-15 19:28:42 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-08-07 23:40:07 +02:00
|
|
|
openapi_spec = OpenAPISpec(OPENAPI_SPEC_PATH)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_schema(endpoint: str, method: str, status_code: str) -> dict[str, Any]:
|
2021-02-12 08:19:30 +01:00
|
|
|
if len(status_code) == 3 and (
|
2021-02-12 08:20:45 +01:00
|
|
|
"oneOf"
|
|
|
|
in openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
|
|
|
"content"
|
|
|
|
]["application/json"]["schema"]
|
2021-02-12 08:19:30 +01:00
|
|
|
):
|
2020-06-13 17:59:46 +02:00
|
|
|
# Currently at places where multiple schemas are defined they only
|
|
|
|
# differ in example so either can be used.
|
2021-02-12 08:20:45 +01:00
|
|
|
status_code += "_0"
|
2020-08-12 04:54:48 +02:00
|
|
|
if len(status_code) == 3:
|
2021-02-12 08:20:45 +01:00
|
|
|
schema = openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][
|
2021-02-12 08:19:30 +01:00
|
|
|
status_code
|
2021-02-12 08:20:45 +01:00
|
|
|
]["content"]["application/json"]["schema"]
|
2020-04-17 19:16:43 +02:00
|
|
|
return schema
|
|
|
|
else:
|
2020-08-12 04:54:48 +02:00
|
|
|
subschema_index = int(status_code[4])
|
|
|
|
status_code = status_code[0:3]
|
2021-02-12 08:20:45 +01:00
|
|
|
schema = openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][
|
2021-02-12 08:19:30 +01:00
|
|
|
status_code
|
2021-02-12 08:20:45 +01:00
|
|
|
]["content"]["application/json"]["schema"]["oneOf"][subschema_index]
|
2020-04-17 19:16:43 +02:00
|
|
|
return schema
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-08-11 08:05:10 +02:00
|
|
|
def get_openapi_fixture(
|
|
|
|
endpoint: str, method: str, status_code: str = "200"
|
|
|
|
) -> list[dict[str, Any]]:
|
2021-02-12 08:19:30 +01:00
|
|
|
"""Fetch a fixture from the full spec object."""
|
2024-08-11 08:05:10 +02:00
|
|
|
if "example" not in get_schema(endpoint, method, status_code):
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
|
|
|
"content"
|
|
|
|
]["application/json"]["examples"].values()
|
|
|
|
return [
|
|
|
|
{
|
|
|
|
"description": get_schema(endpoint, method, status_code)["description"],
|
|
|
|
"value": get_schema(endpoint, method, status_code)["example"],
|
|
|
|
}
|
|
|
|
]
|
2021-06-07 22:14:34 +02:00
|
|
|
|
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_curl_include_exclude(endpoint: str, method: str) -> list[dict[str, Any]]:
|
2021-06-21 12:53:05 +02:00
|
|
|
"""Fetch all the kinds of parameters required for curl examples."""
|
|
|
|
if (
|
|
|
|
"x-curl-examples-parameters"
|
|
|
|
not in openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
|
|
|
):
|
|
|
|
return [{"type": "exclude", "parameters": {"enum": [""]}}]
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()]["x-curl-examples-parameters"][
|
|
|
|
"oneOf"
|
|
|
|
]
|
|
|
|
|
|
|
|
|
2021-06-11 20:07:45 +02:00
|
|
|
def check_requires_administrator(endpoint: str, method: str) -> bool:
|
|
|
|
"""Fetch if the endpoint requires admin config."""
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
|
|
|
"x-requires-administrator", False
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-07-12 02:30:23 +02:00
|
|
|
def check_additional_imports(endpoint: str, method: str) -> list[str] | None:
|
2021-06-24 15:59:47 +02:00
|
|
|
"""Fetch the additional imports required for an endpoint."""
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
|
|
|
"x-python-examples-extra-imports", None
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2021-06-21 21:56:18 +02:00
|
|
|
def get_responses_description(endpoint: str, method: str) -> str:
|
|
|
|
"""Fetch responses description of an endpoint."""
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
|
|
|
"x-response-description", ""
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2021-06-21 22:22:27 +02:00
|
|
|
def get_parameters_description(endpoint: str, method: str) -> str:
|
|
|
|
"""Fetch parameters description of an endpoint."""
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
|
|
|
"x-parameter-description", ""
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def generate_openapi_fixture(endpoint: str, method: str) -> list[str]:
|
2021-06-07 22:14:34 +02:00
|
|
|
"""Generate fixture to be rendered"""
|
|
|
|
fixture = []
|
2021-07-13 17:33:43 +02:00
|
|
|
for status_code in sorted(
|
|
|
|
openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"]
|
2021-06-07 22:14:34 +02:00
|
|
|
):
|
2021-07-13 17:33:43 +02:00
|
|
|
if (
|
|
|
|
"oneOf"
|
|
|
|
in openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
2021-06-07 22:14:34 +02:00
|
|
|
"content"
|
2021-07-13 17:33:43 +02:00
|
|
|
]["application/json"]["schema"]
|
|
|
|
):
|
|
|
|
subschema_count = len(
|
|
|
|
openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
|
|
|
"content"
|
|
|
|
]["application/json"]["schema"]["oneOf"]
|
|
|
|
)
|
2021-06-07 22:14:34 +02:00
|
|
|
else:
|
2021-07-13 17:33:43 +02:00
|
|
|
subschema_count = 1
|
|
|
|
for subschema_index in range(subschema_count):
|
|
|
|
if subschema_count != 1:
|
|
|
|
subschema_status_code = status_code + "_" + str(subschema_index)
|
|
|
|
else:
|
|
|
|
subschema_status_code = status_code
|
|
|
|
fixture_dict = get_openapi_fixture(endpoint, method, subschema_status_code)
|
2024-08-11 08:05:10 +02:00
|
|
|
for example in fixture_dict:
|
|
|
|
fixture_json = json.dumps(
|
|
|
|
example["value"], indent=4, sort_keys=True, separators=(",", ": ")
|
|
|
|
)
|
|
|
|
if "description" in example:
|
|
|
|
fixture.extend(example["description"].strip().splitlines())
|
|
|
|
fixture.append("``` json")
|
|
|
|
fixture.extend(fixture_json.splitlines())
|
|
|
|
fixture.append("```")
|
2021-06-07 22:14:34 +02:00
|
|
|
return fixture
|
|
|
|
|
|
|
|
|
2020-04-28 12:13:46 +02:00
|
|
|
def get_openapi_description(endpoint: str, method: str) -> str:
|
2021-02-12 08:19:30 +01:00
|
|
|
"""Fetch a description from the full spec object."""
|
2023-10-25 16:59:10 +02:00
|
|
|
endpoint_documentation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
|
|
|
endpoint_description = endpoint_documentation["description"]
|
2024-02-01 05:43:43 +01:00
|
|
|
check_deprecated_consistency(
|
|
|
|
endpoint_documentation.get("deprecated", False), endpoint_description
|
|
|
|
)
|
2023-10-25 16:59:10 +02:00
|
|
|
return endpoint_description
|
2020-04-28 12:13:46 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-05-22 13:11:23 +02:00
|
|
|
def get_openapi_summary(endpoint: str, method: str) -> str:
|
|
|
|
"""Fetch a summary from the full spec object."""
|
|
|
|
return openapi_spec.openapi()["paths"][endpoint][method.lower()]["summary"]
|
|
|
|
|
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_endpoint_from_operationid(operationid: str) -> tuple[str, str]:
|
2021-05-23 09:46:10 +02:00
|
|
|
for endpoint in openapi_spec.openapi()["paths"]:
|
|
|
|
for method in openapi_spec.openapi()["paths"][endpoint]:
|
|
|
|
operationId = openapi_spec.openapi()["paths"][endpoint][method].get("operationId")
|
|
|
|
if operationId == operationid:
|
|
|
|
return (endpoint, method)
|
|
|
|
raise AssertionError("No such page exists in OpenAPI data.")
|
|
|
|
|
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_openapi_paths() -> set[str]:
|
2021-02-12 08:20:45 +01:00
|
|
|
return set(openapi_spec.openapi()["paths"].keys())
|
2019-07-08 14:08:02 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-02-01 05:43:43 +01:00
|
|
|
NO_EXAMPLE = object()
|
|
|
|
|
|
|
|
|
|
|
|
class Parameter(BaseModel):
|
2024-02-01 06:07:29 +01:00
|
|
|
kind: Literal["query", "path", "formData"]
|
2024-02-01 05:43:43 +01:00
|
|
|
name: str
|
|
|
|
description: str
|
|
|
|
json_encoded: bool
|
2024-07-12 02:30:17 +02:00
|
|
|
value_schema: dict[str, Any]
|
2024-02-01 05:43:43 +01:00
|
|
|
example: object
|
|
|
|
required: bool
|
|
|
|
deprecated: bool
|
|
|
|
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
def get_openapi_parameters(
|
|
|
|
endpoint: str, method: str, include_url_parameters: bool = True
|
2024-07-12 02:30:17 +02:00
|
|
|
) -> list[Parameter]:
|
2021-02-12 08:20:45 +01:00
|
|
|
operation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
2024-02-01 05:43:43 +01:00
|
|
|
parameters = []
|
|
|
|
|
2019-07-15 22:33:16 +02:00
|
|
|
# We do a `.get()` for this last bit to distinguish documented
|
|
|
|
# endpoints with no parameters (empty list) from undocumented
|
|
|
|
# endpoints (KeyError exception).
|
2024-02-01 05:43:43 +01:00
|
|
|
for parameter in operation.get("parameters", []):
|
|
|
|
# Also, we skip parameters defined in the URL.
|
|
|
|
if not include_url_parameters and parameter["in"] == "path":
|
|
|
|
continue
|
|
|
|
|
|
|
|
json_encoded = "content" in parameter
|
|
|
|
if json_encoded:
|
|
|
|
schema = parameter["content"]["application/json"]["schema"]
|
|
|
|
else:
|
|
|
|
schema = parameter["schema"]
|
|
|
|
|
|
|
|
if "example" in parameter:
|
|
|
|
example = parameter["example"]
|
|
|
|
elif json_encoded and "example" in parameter["content"]["application/json"]:
|
|
|
|
example = parameter["content"]["application/json"]["example"]
|
|
|
|
else:
|
|
|
|
example = schema.get("example", NO_EXAMPLE)
|
|
|
|
|
|
|
|
parameters.append(
|
|
|
|
Parameter(
|
|
|
|
kind=parameter["in"],
|
|
|
|
name=parameter["name"],
|
|
|
|
description=parameter["description"],
|
|
|
|
json_encoded=json_encoded,
|
|
|
|
value_schema=schema,
|
|
|
|
example=example,
|
|
|
|
required=parameter.get("required", False),
|
|
|
|
deprecated=parameter.get("deprecated", False),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2024-02-01 06:07:29 +01:00
|
|
|
if "requestBody" in operation and "application/x-www-form-urlencoded" in (
|
|
|
|
content := operation["requestBody"]["content"]
|
|
|
|
):
|
|
|
|
media_type = content["application/x-www-form-urlencoded"]
|
|
|
|
required = media_type["schema"].get("required", [])
|
|
|
|
for key, schema in media_type["schema"]["properties"].items():
|
|
|
|
json_encoded = (
|
|
|
|
"encoding" in media_type
|
|
|
|
and key in (encodings := media_type["encoding"])
|
|
|
|
and encodings[key].get("contentType") == "application/json"
|
|
|
|
) or schema.get("type") == "object"
|
|
|
|
|
|
|
|
parameters.append(
|
|
|
|
Parameter(
|
|
|
|
kind="formData",
|
|
|
|
name=key,
|
|
|
|
description=schema["description"],
|
|
|
|
json_encoded=json_encoded,
|
|
|
|
value_schema=schema,
|
|
|
|
example=schema.get("example"),
|
|
|
|
required=key in required,
|
|
|
|
deprecated=schema.get("deprecated", False),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2019-08-17 01:21:08 +02:00
|
|
|
return parameters
|
2018-05-31 19:41:17 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_openapi_return_values(endpoint: str, method: str) -> dict[str, Any]:
|
2021-02-12 08:20:45 +01:00
|
|
|
operation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
|
|
|
schema = operation["responses"]["200"]["content"]["application/json"]["schema"]
|
2023-09-07 17:45:10 +02:00
|
|
|
# We do not currently have documented endpoints that have multiple schemas
|
|
|
|
# ("oneOf", "anyOf", "allOf") for success ("200") responses. If this changes,
|
|
|
|
# then the assertion below will need to be removed, and this function updated
|
|
|
|
# so that endpoint responses will be rendered as expected.
|
|
|
|
assert "properties" in schema
|
2021-02-12 08:20:45 +01:00
|
|
|
return schema["properties"]
|
2020-08-12 04:54:48 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:23 +02:00
|
|
|
def find_openapi_endpoint(path: str) -> str | None:
|
2020-08-12 04:54:48 +02:00
|
|
|
for path_regex, endpoint in openapi_spec.endpoints_dict().items():
|
|
|
|
matches = re.match(path_regex, path)
|
2020-06-13 17:59:46 +02:00
|
|
|
if matches:
|
2020-08-12 04:54:48 +02:00
|
|
|
return endpoint
|
2020-06-13 17:59:46 +02:00
|
|
|
return None
|
2020-06-02 18:04:03 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
|
|
|
def validate_against_openapi_schema(
|
2024-07-12 02:30:17 +02:00
|
|
|
content: dict[str, Any], path: str, method: str, status_code: str
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> bool:
|
2023-08-10 01:43:12 +02:00
|
|
|
mock_request = MockRequest("http://localhost:9991/", method, "/api/v1" + path)
|
|
|
|
mock_response = MockResponse(
|
|
|
|
orjson.dumps(content),
|
|
|
|
status_code=int(status_code),
|
|
|
|
)
|
|
|
|
return validate_test_response(mock_request, mock_response)
|
|
|
|
|
|
|
|
|
|
|
|
def validate_test_response(request: Request, response: Response) -> bool:
|
2018-05-31 19:41:17 +02:00
|
|
|
"""Compare a "content" dict with the defined schema for a specific method
|
2020-06-13 17:59:46 +02:00
|
|
|
in an endpoint. Return true if validated and false if skipped.
|
2018-05-31 19:41:17 +02:00
|
|
|
"""
|
2020-07-01 19:07:31 +02:00
|
|
|
|
2023-08-10 01:43:12 +02:00
|
|
|
if request.path.startswith("/json/"):
|
|
|
|
path = request.path[len("/json") :]
|
|
|
|
elif request.path.startswith("/api/v1/"):
|
|
|
|
path = request.path[len("/api/v1") :]
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
assert request.method is not None
|
|
|
|
method = request.method.lower()
|
|
|
|
status_code = str(response.status_code)
|
|
|
|
|
2020-07-01 19:07:31 +02:00
|
|
|
# This first set of checks are primarily training wheels that we
|
|
|
|
# hope to eliminate over time as we improve our API documentation.
|
|
|
|
|
2023-07-23 21:20:53 +02:00
|
|
|
if path not in openapi_spec.openapi()["paths"]:
|
2020-08-12 04:54:48 +02:00
|
|
|
endpoint = find_openapi_endpoint(path)
|
2020-06-13 17:59:46 +02:00
|
|
|
# If it doesn't match it hasn't been documented yet.
|
2020-08-12 04:54:48 +02:00
|
|
|
if endpoint is None:
|
2020-06-13 17:59:46 +02:00
|
|
|
return False
|
2020-08-12 04:54:48 +02:00
|
|
|
else:
|
|
|
|
endpoint = path
|
2020-06-13 17:59:46 +02:00
|
|
|
# Excluded endpoint/methods
|
2020-08-12 04:54:48 +02:00
|
|
|
if (endpoint, method) in EXCLUDE_UNDOCUMENTED_ENDPOINTS:
|
2020-06-13 17:59:46 +02:00
|
|
|
return False
|
2020-07-01 19:07:31 +02:00
|
|
|
# Return true for endpoints with only response documentation remaining
|
2023-05-30 00:01:44 +02:00
|
|
|
if (endpoint, method) in EXCLUDE_DOCUMENTED_ENDPOINTS: # nocoverage
|
2020-07-01 19:07:31 +02:00
|
|
|
return True
|
|
|
|
# Code is not declared but appears in various 400 responses. If
|
|
|
|
# common, it can be added to 400 response schema
|
2021-02-12 08:20:45 +01:00
|
|
|
if status_code.startswith("4"):
|
2020-07-01 19:07:31 +02:00
|
|
|
# This return statement should ideally be not here. But since
|
|
|
|
# we have not defined 400 responses for various paths this has
|
|
|
|
# been added as all 400 have the same schema. When all 400
|
|
|
|
# response have been defined this should be removed.
|
2020-06-13 17:59:46 +02:00
|
|
|
return True
|
2022-01-12 03:08:52 +01:00
|
|
|
|
2020-10-22 23:45:38 +02:00
|
|
|
try:
|
2023-08-10 01:43:12 +02:00
|
|
|
openapi_spec.spec().validate_response(request, response)
|
2023-04-04 01:42:32 +02:00
|
|
|
except OpenAPIValidationError as error:
|
|
|
|
message = f"Response validation error at {method} /api/v1{path} ({status_code}):"
|
|
|
|
message += f"\n\n{type(error).__name__}: {error}"
|
2022-04-21 17:55:05 +02:00
|
|
|
message += (
|
|
|
|
"\n\nFor help debugging these errors see: "
|
|
|
|
"https://zulip.readthedocs.io/en/latest/documentation/api.html#debugging-schema-validation-errors"
|
|
|
|
)
|
2022-01-12 03:08:52 +01:00
|
|
|
raise SchemaError(message) from None
|
2020-10-22 23:45:38 +02:00
|
|
|
|
2020-06-13 17:59:46 +02:00
|
|
|
return True
|
2020-06-02 18:04:03 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def validate_schema(schema: dict[str, Any]) -> None:
|
2020-07-01 19:07:31 +02:00
|
|
|
"""Check if opaque objects are present in the OpenAPI spec; this is an
|
|
|
|
important part of our policy for ensuring every detail of Zulip's
|
|
|
|
API responses is correct.
|
2020-06-02 18:04:03 +02:00
|
|
|
|
2020-07-01 19:07:31 +02:00
|
|
|
This is done by checking for the presence of the
|
|
|
|
`additionalProperties` attribute for all objects (dictionaries).
|
|
|
|
"""
|
2021-02-12 08:20:45 +01:00
|
|
|
if "oneOf" in schema:
|
|
|
|
for subschema in schema["oneOf"]:
|
2020-08-12 04:58:01 +02:00
|
|
|
validate_schema(subschema)
|
2021-02-12 08:20:45 +01:00
|
|
|
elif schema["type"] == "array":
|
|
|
|
validate_schema(schema["items"])
|
|
|
|
elif schema["type"] == "object":
|
|
|
|
if "additionalProperties" not in schema:
|
2021-02-12 08:19:30 +01:00
|
|
|
raise SchemaError(
|
2023-01-03 02:16:53 +01:00
|
|
|
"additionalProperties needs to be defined for objects to make sure they have no"
|
|
|
|
" additional properties left to be documented."
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2021-02-12 08:20:45 +01:00
|
|
|
for property_schema in schema.get("properties", {}).values():
|
2020-08-12 04:58:01 +02:00
|
|
|
validate_schema(property_schema)
|
2021-02-12 08:20:45 +01:00
|
|
|
if schema["additionalProperties"]:
|
|
|
|
validate_schema(schema["additionalProperties"])
|
2018-05-31 19:41:17 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-10-25 16:59:10 +02:00
|
|
|
def deprecated_note_in_description(description: str) -> bool:
|
|
|
|
if "**Changes**: Deprecated" in description:
|
2020-06-26 16:18:27 +02:00
|
|
|
return True
|
|
|
|
|
2023-10-25 16:59:10 +02:00
|
|
|
return "**Deprecated**" in description
|
2020-07-09 20:51:31 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-02-01 05:43:43 +01:00
|
|
|
def check_deprecated_consistency(deprecated: bool, description: str) -> None:
|
2021-02-16 13:01:36 +01:00
|
|
|
# Test to make sure deprecated parameters are marked so.
|
2023-10-25 16:59:10 +02:00
|
|
|
if deprecated_note_in_description(description):
|
2024-06-07 23:01:03 +02:00
|
|
|
assert (
|
|
|
|
deprecated
|
|
|
|
), f"Missing `deprecated: true` despite being described as deprecated:\n\n{description}\n"
|
2024-02-01 05:43:43 +01:00
|
|
|
if deprecated:
|
2024-06-07 23:01:03 +02:00
|
|
|
assert deprecated_note_in_description(
|
|
|
|
description
|
|
|
|
), f"Marked as `deprecated: true`, but changes documentation doesn't properly explain as **Deprecated** in the standard format\n\n:{description}\n"
|
2021-02-16 13:01:36 +01:00
|
|
|
|
|
|
|
|
2020-07-09 20:51:31 +02:00
|
|
|
# Skip those JSON endpoints whose query parameters are different from
|
|
|
|
# their `/api/v1` counterpart. This is a legacy code issue that we
|
|
|
|
# plan to fix by changing the implementation.
|
2020-08-12 04:54:48 +02:00
|
|
|
SKIP_JSON = {
|
2021-02-12 08:20:45 +01:00
|
|
|
("/fetch_api_key", "post"),
|
2020-08-12 04:54:48 +02:00
|
|
|
}
|
2020-07-09 20:51:31 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
|
|
|
def validate_request(
|
|
|
|
url: str,
|
|
|
|
method: str,
|
2024-07-12 02:30:23 +02:00
|
|
|
data: str | bytes | Mapping[str, Any],
|
2024-07-12 02:30:17 +02:00
|
|
|
http_headers: dict[str, str],
|
2021-02-12 08:19:30 +01:00
|
|
|
json_url: bool,
|
|
|
|
status_code: str,
|
|
|
|
intentionally_undocumented: bool = False,
|
|
|
|
) -> None:
|
2023-08-10 01:43:12 +02:00
|
|
|
assert isinstance(data, dict)
|
|
|
|
mock_request = MockRequest(
|
|
|
|
"http://localhost:9991/",
|
|
|
|
method,
|
|
|
|
"/api/v1" + url,
|
|
|
|
headers=http_headers,
|
|
|
|
args={k: str(v) for k, v in data.items()},
|
|
|
|
)
|
|
|
|
validate_test_request(mock_request, status_code, intentionally_undocumented)
|
|
|
|
|
|
|
|
|
|
|
|
def validate_test_request(
|
|
|
|
request: Request,
|
|
|
|
status_code: str,
|
|
|
|
intentionally_undocumented: bool = False,
|
|
|
|
) -> None:
|
|
|
|
assert request.method is not None
|
|
|
|
method = request.method.lower()
|
|
|
|
if request.path.startswith("/json/"):
|
|
|
|
url = request.path[len("/json") :]
|
|
|
|
# Some JSON endpoints have different parameters compared to
|
|
|
|
# their `/api/v1` counterparts.
|
|
|
|
if (url, method) in SKIP_JSON:
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
assert request.path.startswith("/api/v1/")
|
|
|
|
url = request.path[len("/api/v1") :]
|
2020-07-09 20:51:31 +02:00
|
|
|
|
|
|
|
# TODO: Add support for file upload endpoints that lack the /json/
|
|
|
|
# or /api/v1/ prefix.
|
2021-02-12 08:20:45 +01:00
|
|
|
if url == "/user_uploads" or url.startswith("/realm/emoji/"):
|
2020-07-09 20:51:31 +02:00
|
|
|
return
|
|
|
|
|
2022-10-06 09:57:41 +02:00
|
|
|
# Requests that do not validate against the OpenAPI spec must either:
|
|
|
|
# * Have returned a 400 (bad request) error
|
|
|
|
# * Have returned a 200 (success) with this request marked as intentionally
|
|
|
|
# undocumented behavior.
|
|
|
|
if status_code.startswith("4"):
|
|
|
|
return
|
|
|
|
if status_code.startswith("2") and intentionally_undocumented:
|
2020-07-09 20:51:31 +02:00
|
|
|
return
|
|
|
|
|
2023-07-10 20:23:27 +02:00
|
|
|
# Now using the openapi_core APIs, validate the request schema
|
|
|
|
# against the OpenAPI documentation.
|
|
|
|
try:
|
2023-08-10 01:43:12 +02:00
|
|
|
openapi_spec.spec().validate_request(request)
|
2023-07-10 20:23:27 +02:00
|
|
|
except OpenAPIValidationError as error:
|
|
|
|
# Show a block error message explaining the options for fixing it.
|
|
|
|
msg = f"""
|
2020-07-09 20:51:31 +02:00
|
|
|
|
|
|
|
Error! The OpenAPI schema for {method} {url} is not consistent
|
|
|
|
with the parameters passed in this HTTP request. Consider:
|
|
|
|
|
|
|
|
* Updating the OpenAPI schema defined in zerver/openapi/zulip.yaml
|
|
|
|
* Adjusting the test to pass valid parameters. If the test
|
2020-07-25 17:24:21 +02:00
|
|
|
fails due to intentionally_undocumented features, you need to pass
|
|
|
|
`intentionally_undocumented=True` to self.client_{method.lower()} or
|
2020-07-09 20:51:31 +02:00
|
|
|
self.api_{method.lower()} to document your intent.
|
|
|
|
|
|
|
|
See https://zulip.readthedocs.io/en/latest/documentation/api.html for help.
|
|
|
|
|
2023-07-10 20:23:27 +02:00
|
|
|
The error logged by the OpenAPI validator is below:
|
|
|
|
{error}
|
|
|
|
"""
|
|
|
|
raise SchemaError(msg)
|