openapi: Validate real requests and responses, not fictional mocks.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 029e765e20)
This commit is contained in:
Anders Kaseorg
2023-08-09 16:43:12 -07:00
committed by Tim Abbott
parent 9f2172c0f9
commit 3bf1934598
3 changed files with 3451 additions and 3304 deletions

View File

@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Literal, Mapping, Optional, Set, Tuple, Unio
import orjson
from openapi_core import OpenAPI
from openapi_core.protocols import Request, Response
from openapi_core.testing import MockRequest, MockResponse
from openapi_core.validation.exceptions import ValidationError as OpenAPIValidationError
from pydantic import BaseModel
@@ -423,10 +424,29 @@ def find_openapi_endpoint(path: str) -> Optional[str]:
def validate_against_openapi_schema(
content: Dict[str, Any], path: str, method: str, status_code: str
) -> bool:
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:
"""Compare a "content" dict with the defined schema for a specific method
in an endpoint. Return true if validated and false if skipped.
"""
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)
# This first set of checks are primarily training wheels that we
# hope to eliminate over time as we improve our API documentation.
@@ -452,14 +472,8 @@ def validate_against_openapi_schema(
# response have been defined this should be removed.
return True
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=int(status_code),
)
try:
openapi_spec.spec().validate_response(mock_request, mock_response)
openapi_spec.spec().validate_response(request, response)
except OpenAPIValidationError as error:
message = f"Response validation error at {method} /api/v1{path} ({status_code}):"
message += f"\n\n{type(error).__name__}: {error}"
@@ -529,10 +543,33 @@ def validate_request(
status_code: str,
intentionally_undocumented: bool = False,
) -> None:
# Some JSON endpoints have different parameters compared to
# their `/api/v1` counterparts.
if json_url and (url, method) in SKIP_JSON:
return
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") :]
# TODO: Add support for file upload endpoints that lack the /json/
# or /api/v1/ prefix.
@@ -550,16 +587,8 @@ def validate_request(
# Now using the openapi_core APIs, validate the request schema
# against the OpenAPI documentation.
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()},
)
try:
openapi_spec.spec().validate_request(mock_request)
openapi_spec.spec().validate_request(request)
except OpenAPIValidationError as error:
# Show a block error message explaining the options for fixing it.
msg = f"""