openapi: Represent OpenAPI parameters with a Parameter class.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg 2024-01-31 20:43:43 -08:00 committed by Anders Kaseorg
parent 0cee3bee00
commit 131b230e2b
4 changed files with 115 additions and 92 deletions

View File

@ -1,6 +1,6 @@
import json
import re
from typing import Any, Dict, List, Mapping, Sequence
from typing import Any, List, Mapping, Sequence
import markdown
from django.utils.html import escape as escape_html
@ -10,6 +10,7 @@ from typing_extensions import override
from zerver.lib.markdown.priorities import PREPROCESSOR_PRIORITES
from zerver.openapi.openapi import (
Parameter,
check_deprecated_consistency,
get_openapi_parameters,
get_parameters_description,
@ -76,19 +77,9 @@ class APIArgumentsTablePreprocessor(Preprocessor):
doc_name = match.group(2)
endpoint, method = doc_name.rsplit(":", 1)
arguments: List[Dict[str, Any]] = []
try:
arguments = get_openapi_parameters(endpoint, method)
except KeyError as e:
# Don't raise an exception if the "parameters"
# field is missing; we assume that's because the
# endpoint doesn't accept any parameters
if e.args != ("parameters",):
raise e
if arguments:
text = self.render_parameters(arguments)
parameters = get_openapi_parameters(endpoint, method)
if parameters:
text = self.render_parameters(parameters)
# We want to show this message only if the parameters
# description doesn't say anything else.
elif get_parameters_description(endpoint, method) == "":
@ -109,58 +100,51 @@ class APIArgumentsTablePreprocessor(Preprocessor):
done = True
return lines
def render_parameters(self, arguments: Sequence[Mapping[str, Any]]) -> List[str]:
parameters = []
def render_parameters(self, parameters: Sequence[Parameter]) -> List[str]:
lines = []
md_engine = markdown.Markdown(extensions=[])
arguments = sorted(arguments, key=lambda argument: "deprecated" in argument)
for argument in arguments:
name = argument.get("argument") or argument.get("name")
description = argument["description"]
enums = argument.get("schema", {}).get("enum")
parameters = sorted(parameters, key=lambda parameter: parameter.deprecated)
for parameter in parameters:
name = parameter.name
description = parameter.description
enums = parameter.value_schema.get("enum")
if enums is not None:
formatted_enums = [
OBJECT_CODE_TEMPLATE.format(value=json.dumps(enum)) for enum in enums
]
description += "\nMust be one of: {}. ".format(", ".join(formatted_enums))
default = argument.get("schema", {}).get("default")
default = parameter.value_schema.get("default")
if default is not None:
description += f"\nDefaults to `{json.dumps(default)}`."
data_type = ""
if "schema" in argument:
data_type = generate_data_type(argument["schema"])
else:
data_type = generate_data_type(argument["content"]["application/json"]["schema"])
data_type = generate_data_type(parameter.value_schema)
# TODO: OpenAPI allows indicating where the argument goes
# (path, querystring, form data...). We should document this detail.
example = ""
if "example" in argument:
# We use this style without explicit JSON encoding for
# integers, strings, and booleans.
# * For booleans, JSON encoding correctly corrects for Python's
# str(True)="True" not matching the encoding of "true".
# * For strings, doing so nicely results in strings being quoted
# in the documentation, improving readability.
# * For integers, it is a noop, since json.dumps(3) == str(3) == "3".
example = json.dumps(argument["example"])
else:
example = json.dumps(argument["content"]["application/json"]["example"])
# We use this style without explicit JSON encoding for
# integers, strings, and booleans.
# * For booleans, JSON encoding correctly corrects for Python's
# str(True)="True" not matching the encoding of "true".
# * For strings, doing so nicely results in strings being quoted
# in the documentation, improving readability.
# * For integers, it is a noop, since json.dumps(3) == str(3) == "3".
example = json.dumps(parameter.example)
required_string: str = "required"
if argument.get("in", "") == "path":
if parameter.kind == "path":
# Any path variable is required
assert argument["required"]
assert parameter.required
required_string = "required in path"
if argument.get("required", False):
if parameter.required:
required_block = f'<span class="api-argument-required">{required_string}</span>'
else:
required_block = '<span class="api-argument-optional">optional</span>'
check_deprecated_consistency(argument, description)
if argument.get("deprecated", False):
check_deprecated_consistency(parameter.deprecated, description)
if parameter.deprecated:
deprecated_block = '<span class="api-argument-deprecated">Deprecated</span>'
else:
deprecated_block = ""
@ -169,17 +153,14 @@ class APIArgumentsTablePreprocessor(Preprocessor):
# TODO: There are some endpoint parameters with object properties
# that are not defined in `zerver/openapi/zulip.yaml`
if "object" in data_type:
if "schema" in argument:
object_schema = argument["schema"]
else:
object_schema = argument["content"]["application/json"]["schema"]
object_schema = parameter.value_schema
if "items" in object_schema and "properties" in object_schema["items"]:
object_block = self.render_object_details(object_schema["items"], str(name))
elif "properties" in object_schema:
object_block = self.render_object_details(object_schema, str(name))
parameters.append(
lines.append(
API_PARAMETER_TEMPLATE.format(
argument=name,
example=escape_html(example),
@ -191,7 +172,7 @@ class APIArgumentsTablePreprocessor(Preprocessor):
)
)
return parameters
return lines
def render_object_details(self, schema: Mapping[str, Any], name: str) -> str:
md_engine = markdown.Markdown(extensions=[])

View File

@ -139,7 +139,9 @@ class APIReturnValuesTablePreprocessor(Preprocessor):
continue
description = return_values[return_value]["description"]
data_type = generate_data_type(return_values[return_value])
check_deprecated_consistency(return_values[return_value], description)
check_deprecated_consistency(
return_values[return_value].get("deprecated", False), description
)
ans.append(self.render_desc(description, spacing, data_type, return_value))
if "properties" in return_values[return_value]:
ans += self.render_table(return_values[return_value]["properties"], spacing + 4)

View File

@ -8,12 +8,13 @@
import json
import os
import re
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
from typing import Any, Dict, List, Literal, Mapping, Optional, Set, Tuple, Union
import orjson
from openapi_core import OpenAPI
from openapi_core.testing import MockRequest, MockResponse
from openapi_core.validation.exceptions import ValidationError as OpenAPIValidationError
from pydantic import BaseModel
OPENAPI_SPEC_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../openapi/zulip.yaml")
@ -294,7 +295,9 @@ def get_openapi_description(endpoint: str, method: str) -> str:
"""Fetch a description from the full spec object."""
endpoint_documentation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
endpoint_description = endpoint_documentation["description"]
check_deprecated_consistency(endpoint_documentation, endpoint_description)
check_deprecated_consistency(
endpoint_documentation.get("deprecated", False), endpoint_description
)
return endpoint_description
@ -316,17 +319,60 @@ def get_openapi_paths() -> Set[str]:
return set(openapi_spec.openapi()["paths"].keys())
NO_EXAMPLE = object()
class Parameter(BaseModel):
kind: Literal["query", "path"]
name: str
description: str
json_encoded: bool
value_schema: Dict[str, Any]
example: object
required: bool
deprecated: bool
def get_openapi_parameters(
endpoint: str, method: str, include_url_parameters: bool = True
) -> List[Dict[str, Any]]:
) -> List[Parameter]:
operation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
parameters = []
# We do a `.get()` for this last bit to distinguish documented
# endpoints with no parameters (empty list) from undocumented
# endpoints (KeyError exception).
parameters = operation.get("parameters", [])
# Also, we skip parameters defined in the URL.
if not include_url_parameters:
parameters = [parameter for parameter in parameters if parameter["in"] != "path"]
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),
)
)
return parameters
@ -433,11 +479,11 @@ def deprecated_note_in_description(description: str) -> bool:
return "**Deprecated**" in description
def check_deprecated_consistency(argument: Mapping[str, Any], description: str) -> None:
def check_deprecated_consistency(deprecated: bool, description: str) -> None:
# Test to make sure deprecated parameters are marked so.
if deprecated_note_in_description(description):
assert argument["deprecated"]
if "deprecated" in argument:
assert deprecated
if deprecated:
assert deprecated_note_in_description(description)

View File

@ -32,6 +32,7 @@ from zerver.openapi.markdown_extension import generate_curl_example, render_curl
from zerver.openapi.openapi import (
OPENAPI_SPEC_PATH,
OpenAPISpec,
Parameter,
SchemaError,
find_openapi_endpoint,
get_openapi_fixture,
@ -96,14 +97,16 @@ class OpenAPIToolsTest(ZulipTestCase):
def test_get_openapi_parameters(self) -> None:
actual = get_openapi_parameters(TEST_ENDPOINT, TEST_METHOD)
expected_item = {
"name": "message_id",
"in": "path",
"description": "The target message's ID.\n",
"example": 43,
"required": True,
"schema": {"type": "integer"},
}
expected_item = Parameter(
kind="path",
name="message_id",
description="The target message's ID.\n",
json_encoded=False,
value_schema={"type": "integer"},
example=43,
required=True,
deprecated=False,
)
assert expected_item in actual
def test_validate_against_openapi_schema(self) -> None:
@ -401,7 +404,7 @@ do not match the types declared in the implementation of {function.__name__}.\n"
raise AssertionError(msg)
def validate_json_schema(
self, function: Callable[..., HttpResponse], openapi_parameters: List[Dict[str, Any]]
self, function: Callable[..., HttpResponse], openapi_parameters: List[Parameter]
) -> None:
"""Validate against the Pydantic generated JSON schema against our OpenAPI definitions"""
USE_JSON_CONTENT_TYPE_HINT = f"""
@ -427,21 +430,18 @@ do not match the types declared in the implementation of {function.__name__}.\n"
# The names of request variables that should have a content type of
# application/json according to our OpenAPI definitions.
json_request_var_names = set()
for expected_param_schema in openapi_parameters:
for openapi_parameter in openapi_parameters:
# We differentiate JSON and non-JSON parameters here. Because
# application/json is the only content type to be verify in the API,
# we assume that as long as "content" is present in the OpenAPI
# spec, the content type should be JSON.
expected_request_var_name = expected_param_schema["name"]
if "content" in expected_param_schema:
expected_param_schema = expected_param_schema["content"]["application/json"][
"schema"
]
expected_request_var_name = openapi_parameter.name
if openapi_parameter.json_encoded:
json_request_var_names.add(expected_request_var_name)
else:
expected_param_schema = expected_param_schema["schema"]
openapi_params.add((expected_request_var_name, schema_type(expected_param_schema)))
openapi_params.add(
(expected_request_var_name, schema_type(openapi_parameter.value_schema))
)
for actual_param in parse_view_func_signature(function).parameters:
actual_param_schema = TypeAdapter(actual_param.param_type).json_schema(
@ -484,7 +484,7 @@ do not match the types declared in the implementation of {function.__name__}.\n"
self.render_openapi_type_exception(function, openapi_params, function_params, diff)
def check_argument_types(
self, function: Callable[..., HttpResponse], openapi_parameters: List[Dict[str, Any]]
self, function: Callable[..., HttpResponse], openapi_parameters: List[Parameter]
) -> None:
"""We construct for both the OpenAPI data and the function's definition a set of
tuples of the form (var_name, type) and then compare those sets to see if the
@ -507,12 +507,9 @@ do not match the types declared in the implementation of {function.__name__}.\n"
openapi_params: Set[Tuple[str, Union[type, Tuple[type, object]]]] = set()
json_params: Dict[str, Union[type, Tuple[type, object]]] = {}
for element in openapi_parameters:
name: str = element["name"]
schema = {}
if "content" in element:
# The only content-type we use in our API is application/json.
assert "schema" in element["content"]["application/json"]
for openapi_parameter in openapi_parameters:
name = openapi_parameter.name
if openapi_parameter.json_encoded:
# If content_type is application/json, then the
# parameter needs to be handled specially, as REQ can
# either return the application/json as a string or it
@ -523,12 +520,9 @@ do not match the types declared in the implementation of {function.__name__}.\n"
#
# Meanwhile `profile_data` in /users/{user_id}: GET is
# taken as array of objects. So treat them separately.
schema = element["content"]["application/json"]["schema"]
json_params[name] = schema_type(schema)
json_params[name] = schema_type(openapi_parameter.value_schema)
continue
else:
schema = element["schema"]
openapi_params.add((name, schema_type(schema)))
openapi_params.add((name, schema_type(openapi_parameter.value_schema)))
function_params: Set[Tuple[str, Union[type, Tuple[type, object]]]] = set()
@ -630,7 +624,7 @@ so maybe we shouldn't include it in pending_endpoints.
# argument list matches what actually appears in the
# codebase.
openapi_parameter_names = {parameter["name"] for parameter in openapi_parameters}
openapi_parameter_names = {parameter.name for parameter in openapi_parameters}
if len(accepted_arguments - openapi_parameter_names) > 0: # nocoverage
print("Undocumented parameters for", url_pattern, method, function_name)