mirror of https://github.com/zulip/zulip.git
openapi: Use openapi_core ResponseValidator to validate responses.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
parent
4cd5e0e578
commit
031f4596ab
|
@ -79,7 +79,6 @@ module = [
|
||||||
"netifaces.*",
|
"netifaces.*",
|
||||||
"onelogin.*",
|
"onelogin.*",
|
||||||
"openapi_core.*",
|
"openapi_core.*",
|
||||||
"openapi_schema_validator.*",
|
|
||||||
"premailer.*",
|
"premailer.*",
|
||||||
"pyinotify.*",
|
"pyinotify.*",
|
||||||
"pyoembed.*",
|
"pyoembed.*",
|
||||||
|
|
|
@ -10,11 +10,13 @@ import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
|
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
|
import orjson
|
||||||
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
||||||
from openapi_core import create_spec
|
from openapi_core import create_spec
|
||||||
from openapi_core.testing import MockRequest
|
from openapi_core.testing import MockRequest, MockResponse
|
||||||
|
from openapi_core.unmarshalling.schemas.exceptions import InvalidSchemaValue
|
||||||
from openapi_core.validation.request.validators import RequestValidator
|
from openapi_core.validation.request.validators import RequestValidator
|
||||||
from openapi_schema_validator import OAS30Validator
|
from openapi_core.validation.response.validators import ResponseValidator
|
||||||
|
|
||||||
OPENAPI_SPEC_PATH = os.path.abspath(
|
OPENAPI_SPEC_PATH = os.path.abspath(
|
||||||
os.path.join(os.path.dirname(__file__), "../openapi/zulip.yaml")
|
os.path.join(os.path.dirname(__file__), "../openapi/zulip.yaml")
|
||||||
|
@ -78,6 +80,7 @@ class OpenAPISpec:
|
||||||
self._openapi: Dict[str, Any] = {}
|
self._openapi: Dict[str, Any] = {}
|
||||||
self._endpoints_dict: Dict[str, str] = {}
|
self._endpoints_dict: Dict[str, str] = {}
|
||||||
self._request_validator: Optional[RequestValidator] = None
|
self._request_validator: Optional[RequestValidator] = None
|
||||||
|
self._response_validator: Optional[ResponseValidator] = None
|
||||||
|
|
||||||
def check_reload(self) -> None:
|
def check_reload(self) -> None:
|
||||||
# Because importing yaml takes significant time, and we only
|
# Because importing yaml takes significant time, and we only
|
||||||
|
@ -104,6 +107,7 @@ class OpenAPISpec:
|
||||||
|
|
||||||
spec = create_spec(openapi)
|
spec = create_spec(openapi)
|
||||||
self._request_validator = RequestValidator(spec)
|
self._request_validator = RequestValidator(spec)
|
||||||
|
self._response_validator = ResponseValidator(spec)
|
||||||
self._openapi = naively_merge_allOf_dict(JsonRef.replace_refs(openapi))
|
self._openapi = naively_merge_allOf_dict(JsonRef.replace_refs(openapi))
|
||||||
self.create_endpoints_dict()
|
self.create_endpoints_dict()
|
||||||
self.mtime = mtime
|
self.mtime = mtime
|
||||||
|
@ -170,6 +174,15 @@ class OpenAPISpec:
|
||||||
assert self._request_validator is not None
|
assert self._request_validator is not None
|
||||||
return self._request_validator
|
return self._request_validator
|
||||||
|
|
||||||
|
def response_validator(self) -> RequestValidator:
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
self.check_reload()
|
||||||
|
assert self._response_validator is not None
|
||||||
|
return self._response_validator
|
||||||
|
|
||||||
|
|
||||||
class SchemaError(Exception):
|
class SchemaError(Exception):
|
||||||
pass
|
pass
|
||||||
|
@ -420,51 +433,57 @@ def validate_against_openapi_schema(
|
||||||
# been added as all 400 have the same schema. When all 400
|
# been added as all 400 have the same schema. When all 400
|
||||||
# response have been defined this should be removed.
|
# response have been defined this should be removed.
|
||||||
return True
|
return True
|
||||||
# The actual work of validating that the response matches the
|
|
||||||
# schema is done via the third-party OAS30Validator.
|
|
||||||
schema = get_schema(endpoint, method, status_code)
|
|
||||||
if endpoint == "/events" and method == "get":
|
if endpoint == "/events" and method == "get":
|
||||||
# This a temporary function for checking only documented events
|
# This a temporary function for checking only documented events
|
||||||
# as all events haven't been documented yet.
|
# as all events haven't been documented yet.
|
||||||
# TODO: Remove this after all events have been documented.
|
# TODO: Remove this after all events have been documented.
|
||||||
fix_events(content)
|
fix_events(content)
|
||||||
|
|
||||||
validator = OAS30Validator(schema)
|
mock_request = MockRequest("http://localhost:9991/", method, "/api/v1" + path)
|
||||||
|
mock_response = MockResponse(
|
||||||
|
# TODO: Use original response content instead of re-serializing it.
|
||||||
|
orjson.dumps(content),
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
result = openapi_spec.response_validator().validate(mock_request, mock_response)
|
||||||
try:
|
try:
|
||||||
validator.validate(content)
|
result.raise_for_errors()
|
||||||
except JsonSchemaValidationError as error:
|
except InvalidSchemaValue as isv:
|
||||||
if not display_brief_error:
|
message = f"{len(isv.schema_errors)} response validation error(s) at {method} /api/v1{path} ({status_code}):"
|
||||||
raise error
|
for error in isv.schema_errors:
|
||||||
|
if display_brief_error:
|
||||||
|
# display_brief_error is designed to avoid printing 1000 lines
|
||||||
|
# of output when the schema to validate is extremely large
|
||||||
|
# (E.g. the several dozen format variants for individual
|
||||||
|
# events returned by GET /events) and instead just display the
|
||||||
|
# specific variant we expect to match the response.
|
||||||
|
brief_error_validator_value = [
|
||||||
|
validator_value
|
||||||
|
for validator_value in error.validator_value
|
||||||
|
if not prune_schema_by_type(validator_value, error.instance["type"])
|
||||||
|
]
|
||||||
|
brief_error_display_schema = error.schema.copy()
|
||||||
|
if "oneOf" in brief_error_display_schema:
|
||||||
|
brief_error_display_schema["oneOf"] = [
|
||||||
|
i_schema
|
||||||
|
for i_schema in error.schema["oneOf"]
|
||||||
|
if not prune_schema_by_type(i_schema, error.instance["type"])
|
||||||
|
]
|
||||||
|
|
||||||
# display_brief_error is designed to avoid printing 1000 lines
|
# Field list from https://python-jsonschema.readthedocs.io/en/stable/errors/
|
||||||
# of output when the schema to validate is extremely large
|
error = JsonSchemaValidationError(
|
||||||
# (E.g. the several dozen format variants for individual
|
message=error.message,
|
||||||
# events returned by GET /events) and instead just display the
|
validator=error.validator,
|
||||||
# specific variant we expect to match the response.
|
path=error.path,
|
||||||
brief_error_validator_value = [
|
instance=error.instance,
|
||||||
validator_value
|
schema_path=error.schema_path,
|
||||||
for validator_value in error.validator_value
|
schema=brief_error_display_schema,
|
||||||
if not prune_schema_by_type(validator_value, error.instance["type"])
|
validator_value=brief_error_validator_value,
|
||||||
]
|
cause=error.cause,
|
||||||
brief_error_display_schema = error.schema.copy()
|
)
|
||||||
if "oneOf" in brief_error_display_schema:
|
message += f"\n\n{type(error).__name__}: {error}"
|
||||||
brief_error_display_schema["oneOf"] = [
|
raise SchemaError(message) from None
|
||||||
i_schema
|
|
||||||
for i_schema in error.schema["oneOf"]
|
|
||||||
if not prune_schema_by_type(i_schema, error.instance["type"])
|
|
||||||
]
|
|
||||||
|
|
||||||
# Field list from https://python-jsonschema.readthedocs.io/en/stable/errors/
|
|
||||||
raise JsonSchemaValidationError(
|
|
||||||
message=error.message,
|
|
||||||
validator=error.validator,
|
|
||||||
path=error.path,
|
|
||||||
instance=error.instance,
|
|
||||||
schema_path=error.schema_path,
|
|
||||||
schema=brief_error_display_schema,
|
|
||||||
validator_value=brief_error_validator_value,
|
|
||||||
cause=error.cause,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ from unittest.mock import MagicMock, patch
|
||||||
import yaml
|
import yaml
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.utils import regex_helper
|
from django.utils import regex_helper
|
||||||
from jsonschema.exceptions import ValidationError
|
|
||||||
|
|
||||||
from zerver.lib.request import _REQ, arguments_map
|
from zerver.lib.request import _REQ, arguments_map
|
||||||
from zerver.lib.rest import rest_dispatch
|
from zerver.lib.rest import rest_dispatch
|
||||||
|
@ -86,7 +85,7 @@ class OpenAPIToolsTest(ZulipTestCase):
|
||||||
|
|
||||||
def test_validate_against_openapi_schema(self) -> None:
|
def test_validate_against_openapi_schema(self) -> None:
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
ValidationError, r"Additional properties are not allowed \('foo' was unexpected\)"
|
SchemaError, r"Additional properties are not allowed \('foo' was unexpected\)"
|
||||||
):
|
):
|
||||||
bad_content: Dict[str, object] = {
|
bad_content: Dict[str, object] = {
|
||||||
"msg": "",
|
"msg": "",
|
||||||
|
@ -97,7 +96,7 @@ class OpenAPIToolsTest(ZulipTestCase):
|
||||||
bad_content, TEST_ENDPOINT, TEST_METHOD, TEST_RESPONSE_SUCCESS
|
bad_content, TEST_ENDPOINT, TEST_METHOD, TEST_RESPONSE_SUCCESS
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(ValidationError, r"42 is not of type string"):
|
with self.assertRaisesRegex(SchemaError, r"42 is not of type string"):
|
||||||
bad_content = {
|
bad_content = {
|
||||||
"msg": 42,
|
"msg": 42,
|
||||||
"result": "success",
|
"result": "success",
|
||||||
|
@ -106,7 +105,7 @@ class OpenAPIToolsTest(ZulipTestCase):
|
||||||
bad_content, TEST_ENDPOINT, TEST_METHOD, TEST_RESPONSE_SUCCESS
|
bad_content, TEST_ENDPOINT, TEST_METHOD, TEST_RESPONSE_SUCCESS
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(ValidationError, r"'msg' is a required property"):
|
with self.assertRaisesRegex(SchemaError, r"'msg' is a required property"):
|
||||||
bad_content = {
|
bad_content = {
|
||||||
"result": "success",
|
"result": "success",
|
||||||
}
|
}
|
||||||
|
@ -148,7 +147,7 @@ class OpenAPIToolsTest(ZulipTestCase):
|
||||||
"200",
|
"200",
|
||||||
)
|
)
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
ValidationError,
|
SchemaError,
|
||||||
r"\{'obj': \{'str3': 'test', 'str4': 'extraneous'\}\} is not valid under any of the given schemas",
|
r"\{'obj': \{'str3': 'test', 'str4': 'extraneous'\}\} is not valid under any of the given schemas",
|
||||||
):
|
):
|
||||||
validate_against_openapi_schema(
|
validate_against_openapi_schema(
|
||||||
|
|
Loading…
Reference in New Issue