feat(scheduler): add generic REST API executor for universal HTTP task execution

Add rest_api_executor as a universal executor that can call any REST API
endpoint across the system. This provides a standard way to trigger HTTP
operations from scheduled tasks.

Features:
- All HTTP methods: GET, POST, PUT, DELETE, PATCH
- Authentication: Bearer token, Basic auth, API key
- Environment variable substitution: ${VAR_NAME}
- JSONPath response extraction
- Configurable timeouts and SSL verification
- Sensitive data redaction in logs
- Custom headers support

This executor enables scheduler to call any service endpoint (Library Desk,
Core API, external webhooks) without needing service-specific executors.

Example usage:
{
  "executor": "rest_api_executor",
  "config": {
    "url": "http://library-desk:8089/consolidate/knowledge",
    "method": "POST",
    "payload": {"process_limit": 10},
    "auth": {"type": "bearer", "token": "${API_KEY}"}
  }
}
This commit is contained in:
2025-12-09 14:26:46 +01:00
parent 5263523fcd
commit 65a91ab6f5
2 changed files with 897 additions and 0 deletions
@@ -0,0 +1,266 @@
"""
Generic REST API Executor
Universal executor for calling any REST API endpoint across the system.
Supports GET, POST, PUT, DELETE with configurable payloads, headers, and authentication.
This executor can be used to trigger any service endpoint:
- Library Desk knowledge consolidation
- Core API operations
- External webhooks
- Any HTTP-based task
Config schema:
{
"url": "http://service:port/endpoint",
"method": "POST", # GET, POST, PUT, DELETE, PATCH
"payload": {...}, # Request body (for POST/PUT/PATCH)
"headers": {...}, # Additional headers
"auth": {
"type": "bearer", # bearer, basic, api_key
"token": "${ENV_VAR}", # Use ${VAR} for env vars
"header": "Authorization" # Optional: header name for API key
},
"timeout": 300, # Timeout in seconds (default: 300)
"verify_ssl": true, # SSL verification (default: true)
"success_codes": [200, 201, 202], # Expected success codes
"response_path": "result.message" # JSONPath to extract from response
}
Example configs:
1. Library Desk Knowledge Consolidation:
{
"url": "http://library-desk:8089/consolidate/knowledge",
"method": "POST",
"payload": {"process_limit": 10, "lookback_days": 7, "dry_run": false},
"auth": {"type": "bearer", "token": "${LIBRARY_DESK_API_KEY}"}
}
2. Core API Container Restart:
{
"url": "http://core-api:8088/v1/infrastructure/containers/nginx/restart",
"method": "POST",
"auth": {"type": "bearer", "token": "${CORE_API_KEY}"}
}
3. External Webhook:
{
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"method": "POST",
"payload": {"text": "Scheduled task completed"},
"verify_ssl": true
}
"""
import logging
import os
import re
import httpx
from typing import Any, Dict, Optional
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Execute REST API call with configured parameters.
Args:
config: REST API call configuration (see module docstring)
settings: Global scheduler settings
Returns:
Response summary or extracted result
Raises:
ValueError: On configuration error
Exception: On API call failure
"""
# Required configuration
url = config.get('url')
if not url:
raise ValueError("Missing required config: 'url'")
method = config.get('method', 'POST').upper()
if method not in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']:
raise ValueError(f"Invalid HTTP method: {method}")
# Optional configuration
payload = config.get('payload', {})
headers = config.get('headers', {})
timeout = config.get('timeout', 300)
verify_ssl = config.get('verify_ssl', True)
success_codes = config.get('success_codes', [200, 201, 202, 204])
response_path = config.get('response_path')
# Handle authentication
auth_config = config.get('auth', {})
if auth_config:
auth_header = _build_auth_header(auth_config, settings)
if auth_header:
headers.update(auth_header)
# Substitute environment variables in URL and payload
url = _substitute_env_vars(url)
payload = _substitute_env_vars_recursive(payload)
logger.info(f"Executing REST API call: {method} {url}")
if payload:
logger.debug(f"Payload: {_redact_sensitive(payload)}")
# Make HTTP request
try:
async with httpx.AsyncClient(timeout=timeout, verify=verify_ssl) as client:
if method == 'GET':
response = await client.get(url, headers=headers)
elif method == 'POST':
response = await client.post(url, json=payload, headers=headers)
elif method == 'PUT':
response = await client.put(url, json=payload, headers=headers)
elif method == 'DELETE':
response = await client.delete(url, headers=headers)
elif method == 'PATCH':
response = await client.patch(url, json=payload, headers=headers)
# Check status code
if response.status_code not in success_codes:
error_msg = (
f"API call failed with status {response.status_code}: "
f"{response.text[:500]}"
)
logger.error(error_msg)
raise Exception(error_msg)
# Parse response
try:
response_data = response.json()
except:
response_data = {"text": response.text}
# Extract specific field if response_path provided
result_text = None
if response_path and isinstance(response_data, dict):
result_text = _extract_json_path(response_data, response_path)
if not result_text:
# Build summary from response
if isinstance(response_data, dict):
# Look for common result fields
result_text = (
response_data.get('message') or
response_data.get('result') or
response_data.get('summary') or
f"Success ({response.status_code})"
)
else:
result_text = f"Success ({response.status_code})"
logger.info(f"API call succeeded: {result_text}")
return str(result_text)
except httpx.HTTPStatusError as e:
error_msg = f"HTTP {e.response.status_code}: {e.response.text[:500]}"
logger.error(error_msg)
raise Exception(error_msg)
except httpx.RequestError as e:
error_msg = f"Request failed: {str(e)}"
logger.error(error_msg)
raise Exception(error_msg)
except Exception as e:
logger.error(f"REST API call failed: {e}", exc_info=True)
raise
def _build_auth_header(auth_config: dict, settings: Settings) -> Optional[Dict[str, str]]:
"""Build authentication header from config."""
auth_type = auth_config.get('type', '').lower()
if auth_type == 'bearer':
token = auth_config.get('token', '')
token = _substitute_env_vars(token)
if token:
return {"Authorization": f"Bearer {token}"}
elif auth_type == 'basic':
username = _substitute_env_vars(auth_config.get('username', ''))
password = _substitute_env_vars(auth_config.get('password', ''))
if username and password:
import base64
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {credentials}"}
elif auth_type == 'api_key':
key = _substitute_env_vars(auth_config.get('key', ''))
header_name = auth_config.get('header', 'X-API-Key')
if key:
return {header_name: key}
return None
def _substitute_env_vars(text: str) -> str:
"""Substitute ${ENV_VAR} placeholders with environment variables."""
if not isinstance(text, str):
return text
# Find all ${VAR} patterns
pattern = r'\$\{([A-Z_][A-Z0-9_]*)\}'
matches = re.findall(pattern, text)
for var_name in matches:
env_value = os.getenv(var_name, '')
if not env_value:
logger.warning(f"Environment variable not found: {var_name}")
text = text.replace(f"${{{var_name}}}", env_value)
return text
def _substitute_env_vars_recursive(data: Any) -> Any:
"""Recursively substitute environment variables in nested structures."""
if isinstance(data, dict):
return {k: _substitute_env_vars_recursive(v) for k, v in data.items()}
elif isinstance(data, list):
return [_substitute_env_vars_recursive(item) for item in data]
elif isinstance(data, str):
return _substitute_env_vars(data)
else:
return data
def _extract_json_path(data: dict, path: str) -> Optional[str]:
"""
Extract value from nested dict using dot notation.
Example: "result.message" -> data["result"]["message"]
"""
try:
keys = path.split('.')
value = data
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
return None
return str(value) if value is not None else None
except:
return None
def _redact_sensitive(data: Any) -> Any:
"""Redact sensitive fields from logs."""
if isinstance(data, dict):
redacted = {}
sensitive_keys = ['password', 'token', 'api_key', 'secret', 'auth']
for k, v in data.items():
if any(s in k.lower() for s in sensitive_keys):
redacted[k] = '***REDACTED***'
else:
redacted[k] = _redact_sensitive(v)
return redacted
elif isinstance(data, list):
return [_redact_sensitive(item) for item in data]
else:
return data
@@ -0,0 +1,631 @@
"""
Comprehensive tests for the generic REST API executor.
Tests cover:
- All HTTP methods (GET, POST, PUT, DELETE, PATCH)
- Authentication types (Bearer, Basic, API Key)
- Environment variable substitution
- Error handling
- Response extraction
- Sensitive data redaction
Run with: pytest tests/test_rest_api_executor.py -v -s
"""
import pytest
import asyncio
import os
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from src.executors import rest_api_executor
from src.config import Settings
@pytest.mark.executor
@pytest.mark.unit
class TestRestApiExecutor:
"""Tests for rest_api_executor module."""
@pytest.fixture
def mock_httpx_client(self):
"""Mock httpx AsyncClient."""
return AsyncMock()
@pytest.fixture
def base_config(self):
"""Base configuration for REST API calls."""
return {
"url": "http://test-service:8080/api/endpoint",
"method": "POST"
}
# Basic HTTP Method Tests
@pytest.mark.asyncio
async def test_post_request_success(self, test_settings: Settings):
"""Test successful POST request."""
config = {
"url": "http://httpbin.org/post",
"method": "POST",
"payload": {"test": "data"},
"timeout": 10
}
# Mock httpx response
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Success"}
mock_response.text = "Success"
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert "Success" in result
@pytest.mark.asyncio
async def test_get_request_success(self, test_settings: Settings):
"""Test successful GET request."""
config = {
"url": "http://httpbin.org/get",
"method": "GET",
"timeout": 10
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result": "GET success"}
mock_response.text = "Success"
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert result is not None
@pytest.mark.asyncio
async def test_put_request_success(self, test_settings: Settings):
"""Test successful PUT request."""
config = {
"url": "http://httpbin.org/put",
"method": "PUT",
"payload": {"update": "data"}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Updated"}
mock_response.text = "Updated"
mock_client.return_value.__aenter__.return_value.put = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert "Updated" in result
@pytest.mark.asyncio
async def test_delete_request_success(self, test_settings: Settings):
"""Test successful DELETE request."""
config = {
"url": "http://httpbin.org/delete",
"method": "DELETE"
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.json.side_effect = Exception("No JSON")
mock_response.text = ""
mock_client.return_value.__aenter__.return_value.delete = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert "204" in result
@pytest.mark.asyncio
async def test_patch_request_success(self, test_settings: Settings):
"""Test successful PATCH request."""
config = {
"url": "http://httpbin.org/patch",
"method": "PATCH",
"payload": {"field": "value"}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Patched"}
mock_response.text = "Patched"
mock_client.return_value.__aenter__.return_value.patch = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert "Patched" in result
# Authentication Tests
@pytest.mark.asyncio
async def test_bearer_auth(self, test_settings: Settings):
"""Test Bearer token authentication."""
config = {
"url": "http://test-service/api",
"method": "GET",
"auth": {
"type": "bearer",
"token": "test-token-123"
}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Authenticated"}
mock_response.text = "Authenticated"
mock_get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.get = mock_get
result = await rest_api_executor.execute(config, test_settings)
# Verify Authorization header was set
call_args = mock_get.call_args
headers = call_args.kwargs.get('headers', {})
assert 'Authorization' in headers
assert headers['Authorization'] == 'Bearer test-token-123'
@pytest.mark.asyncio
async def test_basic_auth(self, test_settings: Settings):
"""Test Basic authentication."""
config = {
"url": "http://test-service/api",
"method": "GET",
"auth": {
"type": "basic",
"username": "testuser",
"password": "testpass"
}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Authenticated"}
mock_response.text = "Authenticated"
mock_get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.get = mock_get
result = await rest_api_executor.execute(config, test_settings)
# Verify Authorization header was set
call_args = mock_get.call_args
headers = call_args.kwargs.get('headers', {})
assert 'Authorization' in headers
assert headers['Authorization'].startswith('Basic ')
@pytest.mark.asyncio
async def test_api_key_auth(self, test_settings: Settings):
"""Test API Key authentication."""
config = {
"url": "http://test-service/api",
"method": "GET",
"auth": {
"type": "api_key",
"key": "my-api-key-123",
"header": "X-API-Key"
}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Authenticated"}
mock_response.text = "Authenticated"
mock_get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.get = mock_get
result = await rest_api_executor.execute(config, test_settings)
# Verify API key header was set
call_args = mock_get.call_args
headers = call_args.kwargs.get('headers', {})
assert 'X-API-Key' in headers
assert headers['X-API-Key'] == 'my-api-key-123'
# Environment Variable Substitution Tests
@pytest.mark.asyncio
async def test_env_var_substitution_in_url(self, test_settings: Settings):
"""Test environment variable substitution in URL."""
os.environ['TEST_SERVICE_URL'] = 'http://my-service:8080'
config = {
"url": "${TEST_SERVICE_URL}/api/endpoint",
"method": "GET"
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Success"}
mock_response.text = "Success"
mock_get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.get = mock_get
result = await rest_api_executor.execute(config, test_settings)
# Verify URL was substituted
call_args = mock_get.call_args
url = call_args.args[0] if call_args.args else None
assert url == 'http://my-service:8080/api/endpoint'
del os.environ['TEST_SERVICE_URL']
@pytest.mark.asyncio
async def test_env_var_substitution_in_auth_token(self, test_settings: Settings):
"""Test environment variable substitution in auth token."""
os.environ['API_TOKEN'] = 'secret-token-from-env'
config = {
"url": "http://test-service/api",
"method": "GET",
"auth": {
"type": "bearer",
"token": "${API_TOKEN}"
}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.text = "Success"
mock_get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.get = mock_get
result = await rest_api_executor.execute(config, test_settings)
# Verify token was substituted
call_args = mock_get.call_args
headers = call_args.kwargs.get('headers', {})
assert headers['Authorization'] == 'Bearer secret-token-from-env'
del os.environ['API_TOKEN']
@pytest.mark.asyncio
async def test_env_var_substitution_in_payload(self, test_settings: Settings):
"""Test environment variable substitution in payload."""
os.environ['DATABASE_NAME'] = 'test_db'
config = {
"url": "http://test-service/api",
"method": "POST",
"payload": {
"database": "${DATABASE_NAME}",
"action": "backup"
}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.text = "Success"
mock_post = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.post = mock_post
result = await rest_api_executor.execute(config, test_settings)
# Verify payload was substituted
call_args = mock_post.call_args
payload = call_args.kwargs.get('json', {})
assert payload['database'] == 'test_db'
del os.environ['DATABASE_NAME']
# Response Extraction Tests
@pytest.mark.asyncio
async def test_response_path_extraction(self, test_settings: Settings):
"""Test extracting specific field from response."""
config = {
"url": "http://test-service/api",
"method": "GET",
"response_path": "result.message"
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"result": {
"message": "Extracted message",
"other": "ignored"
}
}
mock_response.text = "Success"
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert result == "Extracted message"
@pytest.mark.asyncio
async def test_response_path_not_found(self, test_settings: Settings):
"""Test response path extraction when field doesn't exist."""
config = {
"url": "http://test-service/api",
"method": "GET",
"response_path": "result.missing"
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result": {"other": "data"}}
mock_response.text = "Success"
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
# Should fallback to default message
assert result is not None
# Error Handling Tests
@pytest.mark.asyncio
async def test_missing_url_raises_error(self, test_settings: Settings):
"""Test that missing URL raises ValueError."""
config = {
"method": "GET"
}
with pytest.raises(ValueError, match="Missing required config: 'url'"):
await rest_api_executor.execute(config, test_settings)
@pytest.mark.asyncio
async def test_invalid_http_method_raises_error(self, test_settings: Settings):
"""Test that invalid HTTP method raises ValueError."""
config = {
"url": "http://test-service/api",
"method": "INVALID"
}
with pytest.raises(ValueError, match="Invalid HTTP method"):
await rest_api_executor.execute(config, test_settings)
@pytest.mark.asyncio
async def test_http_error_status_code(self, test_settings: Settings):
"""Test handling of HTTP error status codes."""
config = {
"url": "http://test-service/api",
"method": "GET"
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(Exception, match="API call failed with status 500"):
await rest_api_executor.execute(config, test_settings)
@pytest.mark.asyncio
async def test_custom_success_codes(self, test_settings: Settings):
"""Test custom success codes configuration."""
config = {
"url": "http://test-service/api",
"method": "POST",
"success_codes": [201, 202]
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {"message": "Created"}
mock_response.text = "Created"
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
assert "Created" in result
@pytest.mark.asyncio
async def test_network_error_handling(self, test_settings: Settings):
"""Test handling of network errors."""
config = {
"url": "http://unreachable-service/api",
"method": "GET",
"timeout": 1
}
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
with pytest.raises(Exception, match="Request failed"):
await rest_api_executor.execute(config, test_settings)
# Sensitive Data Redaction Tests
def test_redact_sensitive_data_in_dict(self):
"""Test redaction of sensitive fields in dictionaries."""
data = {
"username": "user",
"password": "secret123",
"api_key": "key123",
"normal_field": "visible"
}
redacted = rest_api_executor._redact_sensitive(data)
assert redacted["username"] == "user"
assert redacted["password"] == "***REDACTED***"
assert redacted["api_key"] == "***REDACTED***"
assert redacted["normal_field"] == "visible"
def test_redact_sensitive_nested(self):
"""Test redaction in nested structures."""
data = {
"config": {
"database": "mydb",
"password": "secret",
"auth": {
"token": "bearer123"
}
}
}
redacted = rest_api_executor._redact_sensitive(data)
assert redacted["config"]["database"] == "mydb"
assert redacted["config"]["password"] == "***REDACTED***"
assert redacted["config"]["auth"]["token"] == "***REDACTED***"
# Helper Function Tests
def test_extract_json_path_simple(self):
"""Test simple JSONPath extraction."""
data = {"message": "test"}
result = rest_api_executor._extract_json_path(data, "message")
assert result == "test"
def test_extract_json_path_nested(self):
"""Test nested JSONPath extraction."""
data = {"result": {"status": {"message": "success"}}}
result = rest_api_executor._extract_json_path(data, "result.status.message")
assert result == "success"
def test_extract_json_path_not_found(self):
"""Test JSONPath extraction when path doesn't exist."""
data = {"message": "test"}
result = rest_api_executor._extract_json_path(data, "missing.path")
assert result is None
def test_substitute_env_vars(self):
"""Test environment variable substitution."""
os.environ['TEST_VAR'] = 'test_value'
result = rest_api_executor._substitute_env_vars("Prefix ${TEST_VAR} suffix")
assert result == "Prefix test_value suffix"
del os.environ['TEST_VAR']
def test_substitute_env_vars_missing(self):
"""Test substitution with missing environment variable."""
result = rest_api_executor._substitute_env_vars("Prefix ${MISSING_VAR} suffix")
# Should replace with empty string
assert result == "Prefix suffix"
def test_substitute_env_vars_recursive(self):
"""Test recursive environment variable substitution."""
os.environ['HOST'] = 'localhost'
os.environ['PORT'] = '8080'
data = {
"url": "http://${HOST}:${PORT}/api",
"nested": {
"key": "${HOST}"
}
}
result = rest_api_executor._substitute_env_vars_recursive(data)
assert result["url"] == "http://localhost:8080/api"
assert result["nested"]["key"] == "localhost"
del os.environ['HOST']
del os.environ['PORT']
# Configuration Options Tests
@pytest.mark.asyncio
async def test_custom_headers(self, test_settings: Settings):
"""Test adding custom headers."""
config = {
"url": "http://test-service/api",
"method": "GET",
"headers": {
"X-Custom-Header": "custom-value",
"User-Agent": "Test-Agent"
}
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.text = "Success"
mock_get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.get = mock_get
result = await rest_api_executor.execute(config, test_settings)
# Verify custom headers were set
call_args = mock_get.call_args
headers = call_args.kwargs.get('headers', {})
assert headers['X-Custom-Header'] == 'custom-value'
assert headers['User-Agent'] == 'Test-Agent'
@pytest.mark.asyncio
async def test_ssl_verification_disabled(self, test_settings: Settings):
"""Test disabling SSL verification."""
config = {
"url": "https://test-service/api",
"method": "GET",
"verify_ssl": False
}
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.text = "Success"
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
result = await rest_api_executor.execute(config, test_settings)
# Verify SSL verification was disabled
client_call = mock_client.call_args
assert client_call.kwargs.get('verify') is False
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])