Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 21 additions & 22 deletions src/matilda_brain/tools/builtins/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
This module provides tools for web searches and HTTP requests.
"""

import asyncio
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Optional, Union

import aiohttp
from matilda_brain.tools import tool

from .config import _get_timeout_bounds, _get_web_timeout, _safe_execute
Expand Down Expand Up @@ -74,7 +76,7 @@ def _web_search_impl(query: str, num_results: int = 5) -> str:


@tool(category="web", description="Make HTTP requests to APIs or websites")
def http_request(
async def http_request(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
Expand Down Expand Up @@ -134,28 +136,25 @@ def http_request(
else:
body_data = str(data).encode("utf-8")

# Create request
req = urllib.request.Request(url, data=body_data, headers=normalized_headers, method=method.upper())

# Make request
with urllib.request.urlopen(req, timeout=timeout) as response:
# Read response
content = response.read().decode("utf-8", errors="replace")

# Try to parse JSON if possible
try:
parsed_json = json.loads(content)
return json.dumps(parsed_json, indent=2)
except json.JSONDecodeError:
return str(content)

except urllib.error.HTTPError as e:
try:
e.close()
except Exception:
pass
return f"HTTP Error {e.code}: {e.reason}"
except urllib.error.URLError as e:
timeout_obj = aiohttp.ClientTimeout(total=timeout)
async with aiohttp.ClientSession(timeout=timeout_obj) as session:
async with session.request(
method.upper(), url, headers=normalized_headers, data=body_data, raise_for_status=True
) as response:
# Read response
content = await response.text(errors="replace")

# Try to parse JSON if possible
try:
parsed_json = json.loads(content)
return json.dumps(parsed_json, indent=2)
except json.JSONDecodeError:
return str(content)

except aiohttp.ClientResponseError as e:
return f"HTTP Error {e.status}: {e.message}"
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
return f"Network error: {str(e)}"
except Exception:
from matilda_brain.internal.utils import get_logger
Expand Down
69 changes: 43 additions & 26 deletions tests/test_tools_builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import json
import urllib.error
from unittest.mock import Mock, patch
from unittest.mock import Mock, patch, AsyncMock

import pytest
import aiohttp

from matilda_brain.tools import get_tool, list_tools
from matilda_brain.tools.builtins import (
Expand Down Expand Up @@ -267,58 +268,74 @@ class TestHttpRequest:
"""Test HTTP request tool."""

@pytest.mark.unit
@patch("urllib.request.urlopen")
def test_http_request_get(self, mock_urlopen):
@pytest.mark.asyncio
@patch("aiohttp.ClientSession.request")
async def test_http_request_get(self, mock_request):
"""Test GET request."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'{"status": "ok"}'
mock_urlopen.return_value.__enter__.return_value = mock_response
mock_response = AsyncMock()
mock_response.text.return_value = '{"status": "ok"}'
mock_response.status = 200
mock_request.return_value.__aenter__.return_value = mock_response

result = http_request("https://api.example.com/test")
result = await http_request("https://api.example.com/test")

# Should pretty-print JSON
assert '"status": "ok"' in result

@pytest.mark.unit
@patch("urllib.request.urlopen")
def test_http_request_post_json(self, mock_urlopen):
@pytest.mark.asyncio
@patch("aiohttp.ClientSession.request")
async def test_http_request_post_json(self, mock_request):
"""Test POST request with JSON data."""
mock_response = Mock()
mock_response.read.return_value = b'{"result": "created"}'
mock_urlopen.return_value.__enter__.return_value = mock_response
mock_response = AsyncMock()
mock_response.text.return_value = '{"result": "created"}'
mock_response.status = 200
mock_request.return_value.__aenter__.return_value = mock_response

result = http_request("https://api.example.com/create", method="POST", data={"name": "test"})
result = await http_request("https://api.example.com/create", method="POST", data={"name": "test"})

assert '"result": "created"' in result

# Check request was made correctly
call_args = mock_urlopen.call_args[0][0]
assert call_args.method == "POST"
# Check Content-Type header (case-insensitive)
headers = call_args.headers
call_args = mock_request.call_args
assert call_args[0][0] == "POST" # Method
assert call_args[0][1] == "https://api.example.com/create" # URL

# Check Content-Type header
headers = call_args[1].get("headers", {})
content_type = headers.get("Content-Type") or headers.get("Content-type")
assert content_type == "application/json"

@pytest.mark.unit
def test_http_request_invalid_url(self):
@pytest.mark.asyncio
async def test_http_request_invalid_url(self):
"""Test invalid URL."""
result = http_request("not-a-url")
result = await http_request("not-a-url")
assert "Error: Invalid URL format" in result

@pytest.mark.unit
def test_http_request_unsupported_protocol(self):
@pytest.mark.asyncio
async def test_http_request_unsupported_protocol(self):
"""Test unsupported protocol."""
result = http_request("ftp://example.com/file")
result = await http_request("ftp://example.com/file")
assert "Error: Only HTTP/HTTPS protocols are supported" in result

@pytest.mark.unit
@patch("urllib.request.urlopen")
def test_http_request_http_error(self, mock_urlopen):
@pytest.mark.asyncio
@patch("aiohttp.ClientSession.request")
async def test_http_request_http_error(self, mock_request):
"""Test HTTP error response."""
mock_urlopen.side_effect = urllib.error.HTTPError("https://api.example.com", 404, "Not Found", {}, None)

result = http_request("https://api.example.com/missing")
# Mock ClientResponseError
error = aiohttp.ClientResponseError(
request_info=Mock(),
history=(),
status=404,
message="Not Found",
)
mock_request.side_effect = error

result = await http_request("https://api.example.com/missing")
assert "HTTP Error 404" in result


Expand Down