mirror of https://github.com/zulip/zulip.git
openapi: Represent OpenAPI parameters with a Parameter class.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
parent
0cee3bee00
commit
131b230e2b
|
@ -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=[])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue