Skip to content

Commit 86bcf1c

Browse files
authored
feat(langchain-sdk): Correctly parse Manifest API response as JSON (#143)
The Manifest API returns a JSON payload. Previously, it was parsed as YAML, which worked due to YAML's superset relationship with JSON. This change explicitly parses the response as JSON for improved robustness and security by ensuring strict adherence to the expected format.
1 parent 2bcdfa3 commit 86bcf1c

File tree

4 files changed

+90
-78
lines changed

4 files changed

+90
-78
lines changed

src/toolbox_langchain_sdk/client.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from langchain_core.tools import StructuredTool
77
from pydantic import BaseModel
88

9-
from .utils import ManifestSchema, _invoke_tool, _load_yaml, _schema_to_model
9+
from .utils import ManifestSchema, _invoke_tool, _load_manifest, _schema_to_model
1010

1111

1212
class ToolboxClient:
@@ -51,8 +51,8 @@ def __del__(self):
5151

5252
async def _load_tool_manifest(self, tool_name: str) -> ManifestSchema:
5353
"""
54-
Fetches and parses the YAML manifest for the given tool from the Toolbox
55-
service.
54+
Fetches and parses the manifest schema for the given tool from the
55+
Toolbox service.
5656
5757
Args:
5858
tool_name: The name of the tool to load.
@@ -61,13 +61,13 @@ async def _load_tool_manifest(self, tool_name: str) -> ManifestSchema:
6161
The parsed Toolbox manifest.
6262
"""
6363
url = f"{self._url}/api/tool/{tool_name}"
64-
return await _load_yaml(url, self._session)
64+
return await _load_manifest(url, self._session)
6565

6666
async def _load_toolset_manifest(
6767
self, toolset_name: Optional[str] = None
6868
) -> ManifestSchema:
6969
"""
70-
Fetches and parses the YAML manifest from the Toolbox service.
70+
Fetches and parses the manifest schema from the Toolbox service.
7171
7272
Args:
7373
toolset_name: The name of the toolset to load.
@@ -78,7 +78,7 @@ async def _load_toolset_manifest(
7878
The parsed Toolbox manifest.
7979
"""
8080
url = f"{self._url}/api/toolset/{toolset_name or ''}"
81-
return await _load_yaml(url, self._session)
81+
return await _load_manifest(url, self._session)
8282

8383
def _validate_auth(self, tool_name: str) -> bool:
8484
"""

src/toolbox_langchain_sdk/utils.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import json
12
import warnings
23
from typing import Any, Callable, Optional, Type, cast
34

4-
import yaml
55
from aiohttp import ClientSession
66
from pydantic import BaseModel, Field, create_model
77

@@ -23,12 +23,13 @@ class ManifestSchema(BaseModel):
2323
tools: dict[str, ToolSchema]
2424

2525

26-
async def _load_yaml(url: str, session: ClientSession) -> ManifestSchema:
26+
async def _load_manifest(url: str, session: ClientSession) -> ManifestSchema:
2727
"""
28-
Asynchronously fetches and parses the YAML data from the given URL.
28+
Asynchronously fetches and parses the JSON manifest schema from the given
29+
URL.
2930
3031
Args:
31-
url: The base URL to fetch the YAML from.
32+
url: The base URL to fetch the JSON from.
3233
session: The HTTP client session
3334
3435
Returns:
@@ -37,18 +38,20 @@ async def _load_yaml(url: str, session: ClientSession) -> ManifestSchema:
3738
async with session.get(url) as response:
3839
response.raise_for_status()
3940
try:
40-
parsed_yaml = yaml.safe_load(await response.text())
41-
except yaml.YAMLError as e:
42-
raise yaml.YAMLError(f"Failed to parse YAML from {url}: {e}") from e
41+
parsed_json = json.loads(await response.text())
42+
except json.JSONDecodeError as e:
43+
raise json.JSONDecodeError(
44+
f"Failed to parse JSON from {url}: {e}", e.doc, e.pos
45+
) from e
4346
try:
44-
return ManifestSchema(**parsed_yaml)
47+
return ManifestSchema(**parsed_json)
4548
except ValueError as e:
46-
raise ValueError(f"Invalid YAML data from {url}: {e}") from e
49+
raise ValueError(f"Invalid JSON data from {url}: {e}") from e
4750

4851

4952
def _schema_to_model(model_name: str, schema: list[ParameterSchema]) -> Type[BaseModel]:
5053
"""
51-
Converts a schema (from the YAML manifest) to a Pydantic BaseModel class.
54+
Converts the given manifest schema to a Pydantic BaseModel class.
5255
5356
Args:
5457
model_name: The name of the model to create.

tests/test_client.py

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,60 +71,60 @@ async def test_close_not_closing_session():
7171

7272

7373
@pytest.mark.asyncio
74-
@patch("toolbox_langchain_sdk.client._load_yaml")
75-
async def test_load_tool_manifest_success(mock_load_yaml):
74+
@patch("toolbox_langchain_sdk.client._load_manifest")
75+
async def test_load_tool_manifest_success(mock_load_manifest):
7676
client = ToolboxClient("https://my-toolbox.com", session=aiohttp.ClientSession())
77-
mock_load_yaml.return_value = ManifestSchema(**manifest_data)
77+
mock_load_manifest.return_value = ManifestSchema(**manifest_data)
7878

7979
result = await client._load_tool_manifest("test_tool")
8080
assert result == ManifestSchema(**manifest_data)
81-
mock_load_yaml.assert_called_once_with(
81+
mock_load_manifest.assert_called_once_with(
8282
"https://my-toolbox.com/api/tool/test_tool", client._session
8383
)
8484

8585

8686
@pytest.mark.asyncio
87-
@patch("toolbox_langchain_sdk.client._load_yaml")
88-
async def test_load_tool_manifest_failure(mock_load_yaml):
87+
@patch("toolbox_langchain_sdk.client._load_manifest")
88+
async def test_load_tool_manifest_failure(mock_load_manifest):
8989
client = ToolboxClient("https://my-toolbox.com", session=aiohttp.ClientSession())
90-
mock_load_yaml.side_effect = Exception("Failed to load YAML")
90+
mock_load_manifest.side_effect = Exception("Failed to load manifest")
9191

9292
with pytest.raises(Exception) as e:
9393
await client._load_tool_manifest("test_tool")
94-
assert str(e.value) == "Failed to load YAML"
94+
assert str(e.value) == "Failed to load manifest"
9595

9696

9797
@pytest.mark.asyncio
98-
@patch("toolbox_langchain_sdk.client._load_yaml")
99-
async def test_load_toolset_manifest_success(mock_load_yaml):
98+
@patch("toolbox_langchain_sdk.client._load_manifest")
99+
async def test_load_toolset_manifest_success(mock_load_manifest):
100100
client = ToolboxClient("https://my-toolbox.com", session=aiohttp.ClientSession())
101-
mock_load_yaml.return_value = ManifestSchema(**manifest_data)
101+
mock_load_manifest.return_value = ManifestSchema(**manifest_data)
102102

103103
# Test with toolset name
104104
result = await client._load_toolset_manifest(toolset_name="test_toolset")
105105
assert result == ManifestSchema(**manifest_data)
106-
mock_load_yaml.assert_called_once_with(
106+
mock_load_manifest.assert_called_once_with(
107107
"https://my-toolbox.com/api/toolset/test_toolset", client._session
108108
)
109-
mock_load_yaml.reset_mock()
109+
mock_load_manifest.reset_mock()
110110

111111
# Test without toolset name
112112
result = await client._load_toolset_manifest()
113113
assert result == ManifestSchema(**manifest_data)
114-
mock_load_yaml.assert_called_once_with(
114+
mock_load_manifest.assert_called_once_with(
115115
"https://my-toolbox.com/api/toolset/", client._session
116116
)
117117

118118

119119
@pytest.mark.asyncio
120-
@patch("toolbox_langchain_sdk.client._load_yaml")
121-
async def test_load_toolset_manifest_failure(mock_load_yaml):
120+
@patch("toolbox_langchain_sdk.client._load_manifest")
121+
async def test_load_toolset_manifest_failure(mock_load_manifest):
122122
client = ToolboxClient("https://my-toolbox.com", session=aiohttp.ClientSession())
123-
mock_load_yaml.side_effect = Exception("Failed to load YAML")
123+
mock_load_manifest.side_effect = Exception("Failed to load manifest")
124124

125125
with pytest.raises(Exception) as e:
126126
await client._load_toolset_manifest(toolset_name="test_toolset")
127-
assert str(e.value) == "Failed to load YAML"
127+
assert str(e.value) == "Failed to load manifest"
128128

129129

130130
@pytest.mark.asyncio
@@ -541,7 +541,7 @@ async def test_process_auth_params(
541541

542542

543543
@pytest.mark.asyncio
544-
@patch("toolbox_langchain_sdk.client._load_yaml")
544+
@patch("toolbox_langchain_sdk.client._load_manifest")
545545
@pytest.mark.parametrize(
546546
"params, auth_headers, expected_tool_param_auth",
547547
[
@@ -569,13 +569,13 @@ async def test_process_auth_params(
569569
],
570570
)
571571
async def test_load_tool(
572-
mock_load_yaml, params, auth_headers, expected_tool_param_auth
572+
mock_load_manifest, params, auth_headers, expected_tool_param_auth
573573
):
574574
"""Test load_tool with and without auth headers."""
575575
client = ToolboxClient("http://test-url")
576576

577577
# Replace with your desired mock manifest data
578-
mock_load_yaml.return_value = ManifestSchema(
578+
mock_load_manifest.return_value = ManifestSchema(
579579
serverVersion="1.0",
580580
tools={
581581
"tool_name": ToolSchema(
@@ -594,7 +594,7 @@ async def test_load_tool(
594594

595595

596596
@pytest.mark.asyncio
597-
@patch("toolbox_langchain_sdk.client._load_yaml")
597+
@patch("toolbox_langchain_sdk.client._load_manifest")
598598
@pytest.mark.parametrize(
599599
"params, auth_headers, expected_tool_param_auth, expected_num_tools",
600600
[
@@ -624,7 +624,7 @@ async def test_load_tool(
624624
],
625625
)
626626
async def test_load_toolset(
627-
mock_load_yaml,
627+
mock_load_manifest,
628628
params,
629629
auth_headers,
630630
expected_tool_param_auth,
@@ -634,7 +634,7 @@ async def test_load_toolset(
634634
client = ToolboxClient("http://test-url")
635635

636636
# Replace with your desired mock manifest data
637-
mock_load_yaml.return_value = ManifestSchema(
637+
mock_load_manifest.return_value = ManifestSchema(
638638
serverVersion="1.0",
639639
tools={
640640
"tool_name": ToolSchema(

tests/test_utils.py

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,52 @@
11
import asyncio
2+
import json
23
import warnings
34
from typing import Union
45
from unittest.mock import AsyncMock, Mock, patch
56

67
import aiohttp
78
import pytest
8-
import yaml
99
from aiohttp import ClientSession
1010
from pydantic import BaseModel
1111

1212
from toolbox_langchain_sdk.utils import (
1313
ParameterSchema,
1414
_convert_none_to_empty_string,
1515
_invoke_tool,
16-
_load_yaml,
16+
_load_manifest,
1717
_parse_type,
1818
_schema_to_model,
1919
)
2020

2121
URL = "https://my-toolbox.com/test"
2222
MOCK_MANIFEST = """
23-
serverVersion: 0.0.1
24-
tools:
25-
test_tool:
26-
summary: Test Tool
27-
description: This is a test tool.
28-
parameters:
29-
- name: param1
30-
type: string
31-
description: Parameter 1
32-
- name: param2
33-
type: integer
34-
description: Parameter 2
23+
{
24+
"serverVersion": "0.0.1",
25+
"tools": {
26+
"test_tool": {
27+
"summary": "Test Tool",
28+
"description": "This is a test tool.",
29+
"parameters": [
30+
{
31+
"name": "param1",
32+
"type": "string",
33+
"description": "Parameter 1"
34+
},
35+
{
36+
"name": "param2",
37+
"type": "integer",
38+
"description": "Parameter 2"
39+
}
40+
]
41+
}
42+
}
43+
}
3544
"""
3645

3746

3847
class TestUtils:
3948
@pytest.fixture(scope="module")
40-
def mock_yaml(self):
49+
def mock_manifest(self):
4150
return aiohttp.ClientResponse(
4251
method="GET",
4352
url=aiohttp.client.URL(URL),
@@ -52,13 +61,13 @@ def mock_yaml(self):
5261

5362
@pytest.mark.asyncio
5463
@patch("aiohttp.ClientSession.get")
55-
async def test_load_yaml(self, mock_get, mock_yaml):
56-
mock_yaml.raise_for_status = Mock()
57-
mock_yaml.text = AsyncMock(return_value=MOCK_MANIFEST)
64+
async def test_load_manifest(self, mock_get, mock_manifest):
65+
mock_manifest.raise_for_status = Mock()
66+
mock_manifest.text = AsyncMock(return_value=MOCK_MANIFEST)
5867

59-
mock_get.return_value = mock_yaml
68+
mock_get.return_value = mock_manifest
6069
session = aiohttp.ClientSession()
61-
manifest = await _load_yaml(URL, session)
70+
manifest = await _load_manifest(URL, session)
6271
await session.close()
6372
mock_get.assert_called_once_with(URL)
6473

@@ -74,55 +83,55 @@ async def test_load_yaml(self, mock_get, mock_yaml):
7483

7584
@pytest.mark.asyncio
7685
@patch("aiohttp.ClientSession.get")
77-
async def test_load_yaml_invalid_yaml(self, mock_get, mock_yaml):
78-
mock_yaml.raise_for_status = Mock()
79-
mock_yaml.text = AsyncMock(return_value="{ invalid yaml")
80-
mock_get.return_value = mock_yaml
86+
async def test_load_manifest_invalid_manifest(self, mock_get, mock_manifest):
87+
mock_manifest.raise_for_status = Mock()
88+
mock_manifest.text = AsyncMock(return_value="{ invalid manifest")
89+
mock_get.return_value = mock_manifest
8190

8291
with pytest.raises(Exception) as e:
8392
session = aiohttp.ClientSession()
84-
await _load_yaml(URL, session)
93+
await _load_manifest(URL, session)
8594
await session.close()
8695
mock_get.assert_called_once_with(URL)
8796

8897
mock_get.assert_called_once_with(URL)
89-
assert isinstance(e.value, yaml.YAMLError)
98+
assert isinstance(e.value, json.JSONDecodeError)
9099
assert (
91100
str(e.value)
92-
== "Failed to parse YAML from https://my-toolbox.com/test: while parsing a flow mapping\n in \"<unicode string>\", line 1, column 1:\n { invalid yaml\n ^\nexpected ',' or '}', but got '<stream end>'\n in \"<unicode string>\", line 1, column 15:\n { invalid yaml\n ^"
101+
== "Failed to parse JSON from https://my-toolbox.com/test: Expecting property name enclosed in double quotes: line 1 column 3 (char 2): line 1 column 3 (char 2)"
93102
)
94103

95104
@pytest.mark.asyncio
96105
@patch("aiohttp.ClientSession.get")
97-
async def test_load_yaml_invalid_manifest(self, mock_get, mock_yaml):
98-
mock_yaml.raise_for_status = Mock()
99-
mock_yaml.text = AsyncMock(return_value="{ invalid yaml }")
100-
mock_get.return_value = mock_yaml
106+
async def test_load_manifest_invalid_manifest(self, mock_get, mock_manifest):
107+
mock_manifest.raise_for_status = Mock()
108+
mock_manifest.text = AsyncMock(return_value='{ "something": "invalid" }')
109+
mock_get.return_value = mock_manifest
101110

102111
with pytest.raises(Exception) as e:
103112
session = aiohttp.ClientSession()
104-
await _load_yaml(URL, session)
113+
await _load_manifest(URL, session)
105114
await session.close()
106115
mock_get.assert_called_once_with(URL)
107116

108117
mock_get.assert_called_once_with(URL)
109118
assert isinstance(e.value, ValueError)
110119
assert (
111120
str(e.value)
112-
== "Invalid YAML data from https://my-toolbox.com/test: 2 validation errors for ManifestSchema\nserverVersion\n Field required [type=missing, input_value={'invalid yaml': None}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntools\n Field required [type=missing, input_value={'invalid yaml': None}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing"
121+
== "Invalid JSON data from https://my-toolbox.com/test: 2 validation errors for ManifestSchema\nserverVersion\n Field required [type=missing, input_value={'something': 'invalid'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing\ntools\n Field required [type=missing, input_value={'something': 'invalid'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.10/v/missing"
113122
)
114123

115124
@pytest.mark.asyncio
116125
@patch("aiohttp.ClientSession.get")
117-
async def test_load_yaml_api_error(self, mock_get, mock_yaml):
126+
async def test_load_manifest_api_error(self, mock_get, mock_manifest):
118127
error = aiohttp.ClientError("Simulated HTTP Error")
119-
mock_yaml.raise_for_status = Mock()
120-
mock_yaml.text = AsyncMock(side_effect=error)
121-
mock_get.return_value = mock_yaml
128+
mock_manifest.raise_for_status = Mock()
129+
mock_manifest.text = AsyncMock(side_effect=error)
130+
mock_get.return_value = mock_manifest
122131

123132
with pytest.raises(aiohttp.ClientError) as exc_info:
124133
session = aiohttp.ClientSession()
125-
await _load_yaml(URL, session)
134+
await _load_manifest(URL, session)
126135
await session.close()
127136
mock_get.assert_called_once_with(URL)
128137
assert exc_info.value == error

0 commit comments

Comments
 (0)