mirror of
https://github.com/thousandeyes/thousandeyes-sdk-python.git
synced 2026-08-04 00:46:52 +00:00
Compare commits
1 Commits
5355a005b5
...
a5951e0f8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5951e0f8b |
@ -14,7 +14,7 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
from typing import Any, Optional, Type
|
from typing import Any, Optional
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
@ -135,22 +135,6 @@ class ApiException(OpenApiException):
|
|||||||
pass
|
pass
|
||||||
self.headers = http_resp.getheaders()
|
self.headers = http_resp.getheaders()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def exception_class_for_http_status(cls, status: int) -> Type["ApiException"]:
|
|
||||||
if status == 400:
|
|
||||||
return BadRequestException
|
|
||||||
if status == 401:
|
|
||||||
return UnauthorizedException
|
|
||||||
if status == 403:
|
|
||||||
return ForbiddenException
|
|
||||||
if status == 404:
|
|
||||||
return NotFoundException
|
|
||||||
if status == 429:
|
|
||||||
return TooManyRequestsException
|
|
||||||
if 500 <= status <= 599:
|
|
||||||
return ServiceException
|
|
||||||
return ApiException
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_response(
|
def from_response(
|
||||||
cls,
|
cls,
|
||||||
@ -159,8 +143,24 @@ class ApiException(OpenApiException):
|
|||||||
body: Optional[str],
|
body: Optional[str],
|
||||||
data: Optional[Any],
|
data: Optional[Any],
|
||||||
) -> Self:
|
) -> Self:
|
||||||
exc_class = cls.exception_class_for_http_status(http_resp.status)
|
if http_resp.status == 400:
|
||||||
raise exc_class(http_resp=http_resp, body=body, data=data)
|
raise BadRequestException(http_resp=http_resp, body=body, data=data)
|
||||||
|
|
||||||
|
if http_resp.status == 401:
|
||||||
|
raise UnauthorizedException(http_resp=http_resp, body=body, data=data)
|
||||||
|
|
||||||
|
if http_resp.status == 403:
|
||||||
|
raise ForbiddenException(http_resp=http_resp, body=body, data=data)
|
||||||
|
|
||||||
|
if http_resp.status == 404:
|
||||||
|
raise NotFoundException(http_resp=http_resp, body=body, data=data)
|
||||||
|
|
||||||
|
if http_resp.status == 429:
|
||||||
|
raise TooManyRequestsException(http_resp=http_resp, body=body, data=data)
|
||||||
|
|
||||||
|
if 500 <= http_resp.status <= 599:
|
||||||
|
raise ServiceException(http_resp=http_resp, body=body, data=data)
|
||||||
|
raise ApiException(http_resp=http_resp, body=body, data=data)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Custom error messages for exception"""
|
"""Custom error messages for exception"""
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_core_test_support = Path(__file__).resolve().parent
|
|
||||||
if str(_core_test_support) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_core_test_support))
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# Test-only helpers for SDK integration tests. Not shipped in the published package.
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
# Copyright 2024 Cisco Systems, Inc. and its affiliates
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from typing import Any, Dict, Mapping, Optional
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from sdk_test_support.mock_server_types import OperationExpectation
|
|
||||||
|
|
||||||
OPERATION_ID_HEADER = "X-TE-Operation-Id"
|
|
||||||
ERROR_STATUS_HEADER = "X-TE-Error-Status"
|
|
||||||
AUTHORIZATION_HEADER = "Authorization"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_scalar(value: Any) -> Any:
|
|
||||||
if isinstance(value, str) and "T" in value:
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).isoformat().replace("+00:00", "Z")
|
|
||||||
except ValueError:
|
|
||||||
return value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_json(value: Any) -> Any:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {key: _normalize_json(value[key]) for key in sorted(value.keys())}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_normalize_json(item) for item in value]
|
|
||||||
return _normalize_scalar(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_body_matches(expected: Any, actual: Any) -> bool:
|
|
||||||
if isinstance(expected, dict) and isinstance(actual, dict):
|
|
||||||
for key in actual:
|
|
||||||
if key not in expected:
|
|
||||||
return False
|
|
||||||
if not _json_body_matches(expected[key], actual[key]):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
if isinstance(expected, list) and isinstance(actual, list):
|
|
||||||
if len(expected) != len(actual):
|
|
||||||
return False
|
|
||||||
return all(_json_body_matches(expected_item, actual_item)
|
|
||||||
for expected_item, actual_item in zip(expected, actual))
|
|
||||||
return _normalize_json(expected) == _normalize_json(actual)
|
|
||||||
|
|
||||||
|
|
||||||
class MockApiServer:
|
|
||||||
def __init__(self, manifest: Mapping[str, OperationExpectation], host: str = "127.0.0.1", port: int = 0):
|
|
||||||
self._manifest = dict(manifest)
|
|
||||||
self._host = host
|
|
||||||
self._port = port
|
|
||||||
self._server: Optional[ThreadingHTTPServer] = None
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def base_url(self) -> str:
|
|
||||||
if self._server is None:
|
|
||||||
raise RuntimeError("MockApiServer has not been started")
|
|
||||||
return f"http://{self._host}:{self._server.server_port}"
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
if self._server is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
manifest = self._manifest
|
|
||||||
handler = _build_handler(manifest)
|
|
||||||
self._server = ThreadingHTTPServer((self._host, self._port), handler)
|
|
||||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
if self._server is None:
|
|
||||||
return
|
|
||||||
self._server.shutdown()
|
|
||||||
self._server.server_close()
|
|
||||||
if self._thread is not None:
|
|
||||||
self._thread.join(timeout=5)
|
|
||||||
self._server = None
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
def __enter__(self) -> "MockApiServer":
|
|
||||||
self.start()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb) -> None:
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_handler(manifest: Mapping[str, OperationExpectation]):
|
|
||||||
class MockApiRequestHandler(BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, format: str, *args) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
self._handle_request("GET")
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
self._handle_request("POST")
|
|
||||||
|
|
||||||
def do_PUT(self) -> None:
|
|
||||||
self._handle_request("PUT")
|
|
||||||
|
|
||||||
def do_PATCH(self) -> None:
|
|
||||||
self._handle_request("PATCH")
|
|
||||||
|
|
||||||
def do_DELETE(self) -> None:
|
|
||||||
self._handle_request("DELETE")
|
|
||||||
|
|
||||||
def _handle_request(self, method: str) -> None:
|
|
||||||
auth_error = _validate_authorization(self.headers.get(AUTHORIZATION_HEADER))
|
|
||||||
if auth_error is not None:
|
|
||||||
self._write_json(auth_error, 401)
|
|
||||||
return
|
|
||||||
|
|
||||||
operation_id = self.headers.get(OPERATION_ID_HEADER)
|
|
||||||
if not operation_id:
|
|
||||||
self._write_json({"detail": f"Missing required header {OPERATION_ID_HEADER}"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
expectation = manifest.get(operation_id)
|
|
||||||
if expectation is None:
|
|
||||||
self._write_json({"detail": f"Unknown operation id {operation_id}"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
if expectation.method.upper() != method.upper():
|
|
||||||
self._write_json(
|
|
||||||
{"detail": f"Unexpected HTTP method {method} for operation {operation_id}"},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if not _path_matches(expectation.path, parsed.path):
|
|
||||||
self._write_json({"detail": "Path does not match operation expectation"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
error_status_header = self.headers.get(ERROR_STATUS_HEADER)
|
|
||||||
if error_status_header:
|
|
||||||
self._handle_error_response(expectation, error_status_header)
|
|
||||||
return
|
|
||||||
|
|
||||||
body_bytes = _read_body(self)
|
|
||||||
if expectation.request_body_example is not None:
|
|
||||||
if not body_bytes:
|
|
||||||
self._write_json({"detail": "Expected request body"}, 400)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
request_json = json.loads(body_bytes.decode("utf-8"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self._write_json({"detail": "Invalid JSON request body"}, 400)
|
|
||||||
return
|
|
||||||
if not _json_body_matches(expectation.request_body_example, request_json):
|
|
||||||
self._write_json({"detail": "Request body does not match OAS example"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
if expectation.success_body is None:
|
|
||||||
self.send_response(expectation.success_status)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
self._write_json(
|
|
||||||
expectation.success_body,
|
|
||||||
expectation.success_status,
|
|
||||||
expectation.success_content_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_error_response(self, expectation: OperationExpectation, error_status_header: str) -> None:
|
|
||||||
error_response = expectation.error_responses.get(error_status_header)
|
|
||||||
if error_response is None:
|
|
||||||
self._write_json(
|
|
||||||
{"detail": f"No configured error response for status {error_status_header}"},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if error_response.body is None:
|
|
||||||
self.send_response(error_response.status)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
self._write_json(error_response.body, error_response.status, error_response.content_type)
|
|
||||||
|
|
||||||
def _write_json(self, body: Any, status: int, content_type: str = "application/json") -> None:
|
|
||||||
payload = json.dumps(body).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(payload)
|
|
||||||
|
|
||||||
return MockApiRequestHandler
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_authorization(value: Optional[str]) -> Optional[Dict[str, str]]:
|
|
||||||
if value is None or not value.strip():
|
|
||||||
return {"detail": f"Missing or empty required header {AUTHORIZATION_HEADER}"}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read_body(handler: BaseHTTPRequestHandler) -> bytes:
|
|
||||||
length = handler.headers.get("Content-Length")
|
|
||||||
if not length:
|
|
||||||
return b""
|
|
||||||
return handler.rfile.read(int(length))
|
|
||||||
|
|
||||||
|
|
||||||
def _path_matches(template: str, actual_path: str) -> bool:
|
|
||||||
pattern = re.sub(r"\{[^/]+\}", r"[^/]+", template)
|
|
||||||
pattern = f"^{pattern}$"
|
|
||||||
return re.match(pattern, actual_path) is not None
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# Copyright 2024 Cisco Systems, Inc. and its affiliates
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ErrorResponseExpectation:
|
|
||||||
status: int
|
|
||||||
body: Any
|
|
||||||
content_type: str = "application/problem+json"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class OperationExpectation:
|
|
||||||
operation_id: str
|
|
||||||
method: str
|
|
||||||
path: str
|
|
||||||
success_status: int
|
|
||||||
success_body: Any = None
|
|
||||||
success_content_type: str = "application/json"
|
|
||||||
request_body_example: Any = None
|
|
||||||
path_param_examples: Dict[str, str] = field(default_factory=dict)
|
|
||||||
query_param_examples: Dict[str, str] = field(default_factory=dict)
|
|
||||||
error_responses: Dict[str, ErrorResponseExpectation] = field(default_factory=dict)
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from thousandeyes_sdk.core.exceptions import (
|
|
||||||
ApiException,
|
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
UnauthorizedException,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("status", "expected"),
|
|
||||||
[
|
|
||||||
(400, BadRequestException),
|
|
||||||
(401, UnauthorizedException),
|
|
||||||
(403, ForbiddenException),
|
|
||||||
(404, NotFoundException),
|
|
||||||
(429, TooManyRequestsException),
|
|
||||||
(500, ServiceException),
|
|
||||||
(503, ServiceException),
|
|
||||||
(418, ApiException),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_exception_class_for_http_status(status, expected):
|
|
||||||
assert ApiException.exception_class_for_http_status(status) is expected
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from sdk_test_support.mock_server import (
|
|
||||||
AUTHORIZATION_HEADER,
|
|
||||||
ERROR_STATUS_HEADER,
|
|
||||||
OPERATION_ID_HEADER,
|
|
||||||
MockApiServer,
|
|
||||||
)
|
|
||||||
from sdk_test_support.mock_server_types import ErrorResponseExpectation, OperationExpectation
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def manifest():
|
|
||||||
return {
|
|
||||||
"createAlertRule": OperationExpectation(
|
|
||||||
operation_id="createAlertRule",
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
request_body_example={"ruleName": "Example"},
|
|
||||||
success_status=201,
|
|
||||||
success_body={"ruleId": "1"},
|
|
||||||
error_responses={
|
|
||||||
"400": ErrorResponseExpectation(
|
|
||||||
status=400,
|
|
||||||
body={"title": "Bad Request", "status": 400},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"deleteAlertRule": OperationExpectation(
|
|
||||||
operation_id="deleteAlertRule",
|
|
||||||
method="DELETE",
|
|
||||||
path="/alerts/rules/{ruleId}",
|
|
||||||
path_param_examples={"ruleId": "127094"},
|
|
||||||
success_status=204,
|
|
||||||
success_body=None,
|
|
||||||
),
|
|
||||||
"getAlertRule": OperationExpectation(
|
|
||||||
operation_id="getAlertRule",
|
|
||||||
method="GET",
|
|
||||||
path="/alerts/rules/{ruleId}",
|
|
||||||
path_param_examples={"ruleId": "127094"},
|
|
||||||
success_status=200,
|
|
||||||
success_body={"ruleId": "127094", "ruleName": "Example"},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _request(server: MockApiServer, *, method: str, path: str, headers=None, body=None):
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
request_headers = {
|
|
||||||
AUTHORIZATION_HEADER: "Bearer test-token",
|
|
||||||
OPERATION_ID_HEADER: "createAlertRule",
|
|
||||||
}
|
|
||||||
if headers:
|
|
||||||
request_headers.update(headers)
|
|
||||||
|
|
||||||
data = None
|
|
||||||
if body is not None:
|
|
||||||
data = json.dumps(body).encode("utf-8")
|
|
||||||
request_headers["Content-Type"] = "application/json"
|
|
||||||
|
|
||||||
request = urllib.request.Request(
|
|
||||||
server.base_url + path,
|
|
||||||
data=data,
|
|
||||||
headers=request_headers,
|
|
||||||
method=method,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request) as response:
|
|
||||||
return response.status, response.read()
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read()
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_happy_path(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
body={"ruleName": "Example"},
|
|
||||||
)
|
|
||||||
assert status == 201
|
|
||||||
assert json.loads(body.decode("utf-8")) == {"ruleId": "1"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_rejects_missing_authorization(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
status, _ = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
headers={AUTHORIZATION_HEADER: ""},
|
|
||||||
body={"ruleName": "Example"},
|
|
||||||
)
|
|
||||||
assert status == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_rejects_invalid_request_body(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
status, _ = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
body={"ruleName": "Wrong"},
|
|
||||||
)
|
|
||||||
assert status == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_error_path(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
headers={ERROR_STATUS_HEADER: "400"},
|
|
||||||
body={"unexpected": True},
|
|
||||||
)
|
|
||||||
assert status == 400
|
|
||||||
assert json.loads(body.decode("utf-8"))["title"] == "Bad Request"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_ignores_readonly_fields_in_expected_body(manifest):
|
|
||||||
readonly_manifest = {
|
|
||||||
**manifest,
|
|
||||||
"createAlertRule": OperationExpectation(
|
|
||||||
operation_id="createAlertRule",
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
request_body_example={"ruleName": "Example", "ruleId": "read-only"},
|
|
||||||
success_status=201,
|
|
||||||
success_body={"ruleId": "1"},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
with MockApiServer(readonly_manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
body={"ruleName": "Example"},
|
|
||||||
)
|
|
||||||
assert status == 201
|
|
||||||
assert json.loads(body.decode("utf-8")) == {"ruleId": "1"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_no_content_response(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
request = urllib.request.Request(
|
|
||||||
server.base_url + "/alerts/rules/127094",
|
|
||||||
headers={
|
|
||||||
AUTHORIZATION_HEADER: "Bearer test-token",
|
|
||||||
OPERATION_ID_HEADER: "deleteAlertRule",
|
|
||||||
},
|
|
||||||
method="DELETE",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request) as response:
|
|
||||||
assert response.status == 204
|
|
||||||
assert response.read() == b""
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_matches_path_variable(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="GET",
|
|
||||||
path="/alerts/rules/127094",
|
|
||||||
headers={OPERATION_ID_HEADER: "getAlertRule"},
|
|
||||||
)
|
|
||||||
assert status == 200
|
|
||||||
assert json.loads(body.decode("utf-8")) == {"ruleId": "127094", "ruleName": "Example"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_rejects_path_missing_path_variable(manifest):
|
|
||||||
with MockApiServer(manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="GET",
|
|
||||||
path="/alerts/rules",
|
|
||||||
headers={OPERATION_ID_HEADER: "getAlertRule"},
|
|
||||||
)
|
|
||||||
assert status == 400
|
|
||||||
assert json.loads(body.decode("utf-8"))["detail"] == "Path does not match operation expectation"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_accepts_equivalent_iso8601_datetime_formats(manifest):
|
|
||||||
datetime_manifest = {
|
|
||||||
**manifest,
|
|
||||||
"createAlertRule": OperationExpectation(
|
|
||||||
operation_id="createAlertRule",
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
request_body_example={
|
|
||||||
"ruleName": "Example",
|
|
||||||
"startDate": "2017-07-01T05:00:00Z",
|
|
||||||
},
|
|
||||||
success_status=201,
|
|
||||||
success_body={"ruleId": "1"},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
with MockApiServer(datetime_manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
body={
|
|
||||||
"ruleName": "Example",
|
|
||||||
"startDate": "2017-07-01T05:00:00+00:00",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert status == 201
|
|
||||||
assert json.loads(body.decode("utf-8")) == {"ruleId": "1"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_mock_server_ignores_readonly_fields_in_nested_request_objects(manifest):
|
|
||||||
nested_manifest = {
|
|
||||||
**manifest,
|
|
||||||
"createAlertRule": OperationExpectation(
|
|
||||||
operation_id="createAlertRule",
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
request_body_example={
|
|
||||||
"ruleName": "Example",
|
|
||||||
"widgets": [
|
|
||||||
{
|
|
||||||
"title": "Widget Title",
|
|
||||||
"id": "read-only-id",
|
|
||||||
"embedUrl": "https://example.com/embed",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
success_status=201,
|
|
||||||
success_body={"ruleId": "1"},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
with MockApiServer(nested_manifest) as server:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
body={
|
|
||||||
"ruleName": "Example",
|
|
||||||
"widgets": [{"title": "Widget Title"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert status == 201
|
|
||||||
assert json.loads(body.decode("utf-8")) == {"ruleId": "1"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_integration_error_assertion_fails_when_deserialized_error_does_not_match_oas_example():
|
|
||||||
"""Generated error-path tests compare ApiException.data to the OAS error example."""
|
|
||||||
import unittest
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
class Error(BaseModel):
|
|
||||||
title: str
|
|
||||||
status: int
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
|
||||||
|
|
||||||
def to_json(self) -> str:
|
|
||||||
return self.model_dump_json()
|
|
||||||
|
|
||||||
mismatched_manifest = {
|
|
||||||
"createAlertRule": OperationExpectation(
|
|
||||||
operation_id="createAlertRule",
|
|
||||||
method="POST",
|
|
||||||
path="/alerts/rules",
|
|
||||||
request_body_example={"ruleName": "Example"},
|
|
||||||
success_status=201,
|
|
||||||
success_body={"ruleId": "1"},
|
|
||||||
error_responses={
|
|
||||||
"400": ErrorResponseExpectation(
|
|
||||||
status=400,
|
|
||||||
body={"title": "Wrong Title", "status": 400},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
oas_example = {"title": "Bad Request", "status": 400}
|
|
||||||
|
|
||||||
with MockApiServer(mismatched_manifest) as server:
|
|
||||||
request = urllib.request.Request(
|
|
||||||
server.base_url + "/alerts/rules",
|
|
||||||
data=json.dumps({"unexpected": True}).encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
AUTHORIZATION_HEADER: "Bearer test-token",
|
|
||||||
OPERATION_ID_HEADER: "createAlertRule",
|
|
||||||
ERROR_STATUS_HEADER: "400",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with pytest.raises(urllib.error.HTTPError) as http_error:
|
|
||||||
urllib.request.urlopen(request)
|
|
||||||
wire_body = json.loads(http_error.value.read().decode("utf-8"))
|
|
||||||
|
|
||||||
exception_data = Error.model_validate(wire_body)
|
|
||||||
|
|
||||||
def assert_constructed_model_matches_example_json(model, loaded_json):
|
|
||||||
test_case = unittest.TestCase()
|
|
||||||
test_case.assertEqual(
|
|
||||||
json.dumps(loaded_json, sort_keys=True),
|
|
||||||
json.dumps(json.loads(model.to_json()), sort_keys=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(AssertionError):
|
|
||||||
assert_constructed_model_matches_example_json(exception_data, oas_example)
|
|
||||||
Loading…
Reference in New Issue
Block a user