Jeremiah Lowin Claude commited on
Commit
c21d179
·
unverified ·
1 Parent(s): b71f614

Fix OpenAPI deepObject style parameter encoding (#1122)

Browse files

* Fix OpenAPI deepObject style parameter encoding

Add support for deepObject style with explode=true to properly serialize
object parameters using bracket notation (param[key]=value) instead of
JSON strings. Fixes #1114.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Create README_OPENAPI.md

---------

Co-authored-by: Claude <noreply@anthropic.com>

README_OPENAPI.md ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastMCP OpenAPI Integration
2
+
3
+ This document explains how FastMCP's OpenAPI integration works, what features are supported, and how to extend it. The OpenAPI functionality is split across two main files:
4
+
5
+ - `server/openapi.py` - High-level FastMCP server implementation and MCP component creation
6
+ - `utilities/openapi.py` - Low-level OpenAPI parsing and intermediate representation
7
+
8
+ ## Architecture Overview
9
+
10
+ ```
11
+ OpenAPI Spec → Parse → HTTPRoute IR → Create MCP Components → FastMCP Server
12
+ ```
13
+
14
+ ### 1. Parsing Phase (`utilities/openapi.py`)
15
+
16
+ OpenAPI specifications are parsed into an intermediate representation (IR) that normalizes differences between OpenAPI 3.0 and 3.1:
17
+
18
+ - **Input**: Raw OpenAPI spec (dict)
19
+ - **Output**: List of `HTTPRoute` objects with normalized parameter information
20
+ - **Key Classes**:
21
+ - `HTTPRoute` - Represents a single operation
22
+ - `ParameterInfo` - Represents a parameter with location, style, explode, etc.
23
+ - `RequestBodyInfo` - Represents request body information
24
+ - `ResponseInfo` - Represents response information
25
+
26
+ ### 2. Component Creation Phase (`server/openapi.py`)
27
+
28
+ HTTPRoute objects are converted into FastMCP components based on route mapping rules:
29
+
30
+ - **Tools** (`OpenAPITool`) - HTTP operations that can be called
31
+ - **Resources** (`OpenAPIResource`) - HTTP endpoints that return data
32
+ - **Resource Templates** (`OpenAPIResourceTemplate`) - Parameterized resources
33
+
34
+ ## Parameter Handling
35
+
36
+ FastMCP supports various OpenAPI parameter serialization styles and formats:
37
+
38
+ ### Supported Parameter Locations
39
+ - `query` - Query string parameters
40
+ - `path` - Path parameters
41
+ - `header` - HTTP headers
42
+ - `cookie` - Cookie parameters (parsed but not used in requests)
43
+
44
+ ### Supported Parameter Styles
45
+
46
+ #### Query Parameters
47
+ - **`form`** (default) - Standard query parameter format
48
+ - `explode=true` (default): `?tags=red&tags=blue`
49
+ - `explode=false`: `?tags=red,blue`
50
+ - **`deepObject`** - Object parameters with bracket notation
51
+ - `explode=true`: `?filter[name]=John&filter[age]=30`
52
+ - `explode=false`: Falls back to JSON string (non-standard, logs warning)
53
+
54
+ #### Path Parameters
55
+ - **`simple`** (default) - Comma-separated for arrays: `/users/1,2,3`
56
+
57
+ #### Header Parameters
58
+ - **`simple`** (default) - Standard header format
59
+
60
+ ### Parameter Type Support
61
+
62
+ #### Arrays
63
+ - String arrays with `explode=true/false`
64
+ - Number arrays with `explode=true/false`
65
+ - Boolean arrays with `explode=true/false`
66
+ - Complex object arrays (basic support, may not handle all cases)
67
+
68
+ #### Objects
69
+ - Objects with `deepObject` style and `explode=true`
70
+ - Objects with other styles fall back to JSON serialization
71
+
72
+ #### Primitives
73
+ - Strings, numbers, booleans
74
+ - Enums
75
+ - Default values
76
+
77
+ ## Request Body Handling
78
+
79
+ ### Supported Content Types
80
+ - `application/json` - JSON request bodies
81
+
82
+ ### Schema Support
83
+ - Object schemas with properties
84
+ - Array schemas
85
+ - Primitive schemas
86
+ - Schema references (`$ref` to local schemas only)
87
+ - Required properties
88
+ - Default values
89
+
90
+ ## Response Handling
91
+
92
+ ### Content Type Detection
93
+ - `application/json` - Parsed as JSON
94
+ - `text/*` - Returned as text
95
+ - `application/xml` - Returned as text
96
+ - Other types - Returned as binary
97
+
98
+ ### Output Schema Generation
99
+ - Success response schemas (200, 201, 202, 204)
100
+ - Object response wrapping for MCP compliance
101
+ - Schema compression (removes unused `$defs`)
102
+
103
+ ## Route Mapping
104
+
105
+ Routes are mapped to MCP component types using `RouteMap` configurations:
106
+
107
+ ```python
108
+ RouteMap(
109
+ methods=["GET", "POST"], # HTTP methods to match
110
+ pattern=r"/api/users/.*", # Regex pattern for path
111
+ mcp_type=MCPType.RESOURCE_TEMPLATE, # Target component type
112
+ tags={"user"}, # OpenAPI tags to match (AND condition)
113
+ mcp_tags={"fastmcp-user"} # Tags to add to created components
114
+ )
115
+ ```
116
+
117
+ ### Default Behavior
118
+ - All routes become **Tools** by default
119
+ - Use route maps to override specific patterns
120
+
121
+ ### Component Types
122
+ - `MCPType.TOOL` - Callable operations
123
+ - `MCPType.RESOURCE` - Static data endpoints
124
+ - `MCPType.RESOURCE_TEMPLATE` - Parameterized data endpoints
125
+ - `MCPType.EXCLUDE` - Skip route entirely
126
+
127
+ ## Known Limitations & Edge Cases
128
+
129
+ ### Parameter Edge Cases
130
+ 1. **Parameter Name Collisions** - When path/query parameters have same names as request body properties, non-body parameters get `__location` suffixes
131
+ 2. **Complex Array Serialization** - Limited support for arrays containing objects
132
+ 3. **Cookie Parameters** - Parsed but not used in requests
133
+ 4. **Non-standard Combinations** - e.g., `deepObject` with `explode=false`
134
+
135
+ ### Request Body Edge Cases
136
+ 1. **Content Type Priority** - Only first available content type is used
137
+ 2. **Nested Objects** - Deep nesting may not serialize correctly
138
+ 3. **Binary Content** - No support for file uploads or binary data
139
+
140
+ ### Response Edge Cases
141
+ 1. **Multiple Content Types** - Only JSON-compatible types are used for output schemas
142
+ 2. **Error Responses** - Not used for MCP output schema generation
143
+ 3. **Response Headers** - Not captured or exposed
144
+
145
+ ### Schema Edge Cases
146
+ 1. **External References** - `$ref` to external files not supported
147
+ 2. **Circular References** - May cause issues in schema processing
148
+ 3. **Polymorphism** - `oneOf`/`anyOf`/`allOf` limited support
149
+
150
+ ## Debugging Tips
151
+
152
+ ### Common Issues
153
+ 1. **"Unknown tool/resource"** - Check route mapping configuration
154
+ 2. **Parameter not found** - Check for name collisions or incorrect style/explode
155
+ 3. **Invalid request format** - Check parameter serialization and content types
156
+ 4. **Schema validation errors** - Check for external refs or complex schemas
157
+
158
+ ### Debugging Tools
159
+ ```python
160
+ # Parse routes to inspect intermediate representation
161
+ routes = parse_openapi_to_http_routes(openapi_spec)
162
+ for route in routes:
163
+ print(f"{route.method} {route.path}")
164
+ for param in route.parameters:
165
+ print(f" {param.name} ({param.location}): style={param.style}, explode={param.explode}")
166
+
167
+ # Check component creation
168
+ server = FastMCP.from_openapi(openapi_spec, client)
169
+ tools = await server.get_tools()
170
+ print(f"Created {len(tools)} tools: {list(tools.keys())}")
171
+ ```
172
+
173
+ ### Logging
174
+ - Set `FASTMCP_LOG_LEVEL=DEBUG` to see detailed parameter processing
175
+ - Look for warnings about non-standard parameter combinations
176
+ - Check for schema parsing errors in logs
177
+
178
+ ## Extension Points
179
+
180
+ ### Adding New Parameter Styles
181
+ 1. Add style handling in `utilities/openapi.py` - `ParameterInfo` class
182
+ 2. Implement serialization logic in `server/openapi.py` - `OpenAPITool.run()`
183
+ 3. Add tests for parsing and serialization
184
+
185
+ ### Adding New Content Types
186
+ 1. Extend request body handling in `OpenAPITool.run()`
187
+ 2. Add response parsing logic for new types
188
+ 3. Update content type priority in utilities
189
+
190
+ ### Custom Route Mapping
191
+ Use `route_map_fn` for complex routing logic:
192
+
193
+ ```python
194
+ def custom_mapper(route: HTTPRoute, current_type: MCPType) -> MCPType:
195
+ if route.path.startswith("/admin"):
196
+ return MCPType.EXCLUDE
197
+ return current_type
198
+
199
+ server = FastMCP.from_openapi(spec, client, route_map_fn=custom_mapper)
200
+ ```
201
+
202
+ ## Testing Patterns
203
+
204
+ ### Unit Tests
205
+ - Test parameter parsing with various styles/explode combinations
206
+ - Test route mapping with different patterns and tags
207
+ - Test schema generation and compression
208
+
209
+ ### Integration Tests
210
+ - Mock HTTP client to verify actual request parameters
211
+ - Test end-to-end component creation and execution
212
+ - Test error handling and edge cases
213
+
214
+ ### Example Test Pattern
215
+ ```python
216
+ async def test_parameter_style():
217
+ # 1. Create OpenAPI spec with specific parameter configuration
218
+ spec = {"openapi": "3.1.0", ...}
219
+
220
+ # 2. Parse and create components
221
+ routes = parse_openapi_to_http_routes(spec)
222
+ tool = OpenAPITool(mock_client, routes[0], ...)
223
+
224
+ # 3. Execute and verify request parameters
225
+ await tool.run({"param": "value"})
226
+ actual_params = mock_client.request.call_args.kwargs["params"]
227
+ assert actual_params == expected_params
228
+ ```
229
+
230
+ ## Testing
231
+
232
+ OpenAPI functionality is tested across multiple files in `tests/server/openapi/`:
233
+
234
+ - `test_basic_functionality.py` - Core component creation and execution
235
+ - `test_explode_integration.py` - Parameter explode behavior
236
+ - `test_deepobject_style.py` - DeepObject style parameter encoding
237
+ - `test_parameter_collisions.py` - Parameter name collision handling
238
+ - `test_openapi_path_parameters.py` - Path parameter serialization
239
+ - `test_configuration.py` - Route mapping and MCP names
240
+ - `test_description_propagation.py` - Schema and description handling
241
+
242
+ When adding new OpenAPI features, create focused test files rather than adding to existing monolithic files.
243
+
244
+ ---
245
+
246
+ *This document should be updated when new OpenAPI features are added or when edge cases are discovered and addressed.*
src/fastmcp/server/openapi.py CHANGED
@@ -29,6 +29,7 @@ from fastmcp.utilities.openapi import (
29
  _combine_schemas,
30
  extract_output_schema_from_responses,
31
  format_array_parameter,
 
32
  format_description_with_responses,
33
  )
34
 
@@ -357,18 +358,36 @@ class OpenAPITool(Tool):
357
  param_value = arguments[p.name]
358
 
359
  if param_value is not None:
360
- # Format array query parameters as comma-separated strings
361
- # following OpenAPI form style (default for query parameters)
362
- if (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  isinstance(param_value, list)
364
  and p.schema_.get("type") == "array"
365
  ):
366
- # Get explode parameter from the parameter info, default is True for query parameters
367
- # If explode is True, the array is serialized as separate parameters
368
- # If explode is False, the array is serialized as a comma-separated string
369
- explode = p.explode if p.explode is not None else True
370
-
371
- if explode:
372
  # When explode=True, we pass the array directly, which HTTPX will serialize
373
  # as multiple parameters with the same name
374
  query_params[p.name] = param_value
@@ -379,7 +398,7 @@ class OpenAPITool(Tool):
379
  )
380
  query_params[p.name] = formatted_value
381
  else:
382
- # Non-array parameters are passed as is
383
  query_params[p.name] = param_value
384
 
385
  # Prepare headers - fix typing by ensuring all values are strings
 
29
  _combine_schemas,
30
  extract_output_schema_from_responses,
31
  format_array_parameter,
32
+ format_deep_object_parameter,
33
  format_description_with_responses,
34
  )
35
 
 
358
  param_value = arguments[p.name]
359
 
360
  if param_value is not None:
361
+ # Handle different parameter styles and types
362
+ param_style = (
363
+ p.style or "form"
364
+ ) # Default style for query parameters is "form"
365
+ param_explode = (
366
+ p.explode if p.explode is not None else True
367
+ ) # Default explode for query is True
368
+
369
+ # Handle deepObject style for object parameters
370
+ if param_style == "deepObject" and isinstance(param_value, dict):
371
+ if param_explode:
372
+ # deepObject with explode=true: object properties become separate parameters
373
+ # e.g., target[id]=123&target[type]=user
374
+ deep_obj_params = format_deep_object_parameter(
375
+ param_value, p.name
376
+ )
377
+ query_params.update(deep_obj_params)
378
+ else:
379
+ # deepObject with explode=false is not commonly used, fallback to JSON
380
+ logger.warning(
381
+ f"deepObject style with explode=false for parameter '{p.name}' is not standard. "
382
+ f"Using JSON serialization fallback."
383
+ )
384
+ query_params[p.name] = json.dumps(param_value)
385
+ # Handle array parameters with form style (default)
386
+ elif (
387
  isinstance(param_value, list)
388
  and p.schema_.get("type") == "array"
389
  ):
390
+ if param_explode:
 
 
 
 
 
391
  # When explode=True, we pass the array directly, which HTTPX will serialize
392
  # as multiple parameters with the same name
393
  query_params[p.name] = param_value
 
398
  )
399
  query_params[p.name] = formatted_value
400
  else:
401
+ # Non-array, non-deepObject parameters are passed as is
402
  query_params[p.name] = param_value
403
 
404
  # Prepare headers - fix typing by ensuring all values are strings
src/fastmcp/utilities/openapi.py CHANGED
@@ -93,6 +93,40 @@ def format_array_parameter(
93
  return str_value
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  class ParameterInfo(FastMCPBaseModel):
97
  """Represents a single parameter for an HTTP operation in our IR."""
98
 
@@ -102,6 +136,7 @@ class ParameterInfo(FastMCPBaseModel):
102
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
103
  description: str | None = None
104
  explode: bool | None = None # OpenAPI explode property for array parameters
 
105
 
106
 
107
  class RequestBodyInfo(FastMCPBaseModel):
@@ -153,6 +188,7 @@ __all__ = [
153
  "JsonSchema",
154
  "parse_openapi_to_http_routes",
155
  "extract_output_schema_from_responses",
 
156
  ]
157
 
158
  # Type variables for generic parser
@@ -415,8 +451,9 @@ class OpenAPIParser(
415
  ):
416
  param_schema_dict["default"] = resolved_media_schema.default
417
 
418
- # Extract explode property if present
419
  explode = getattr(parameter, "explode", None)
 
420
 
421
  # Create parameter info object
422
  param_info = ParameterInfo(
@@ -426,6 +463,7 @@ class OpenAPIParser(
426
  schema=param_schema_dict,
427
  description=parameter.description,
428
  explode=explode,
 
429
  )
430
  extracted_params.append(param_info)
431
  except Exception as e:
 
93
  return str_value
94
 
95
 
96
+ def format_deep_object_parameter(
97
+ param_value: dict, parameter_name: str
98
+ ) -> dict[str, str]:
99
+ """
100
+ Format a dictionary parameter for deepObject style serialization.
101
+
102
+ According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
103
+ object properties as separate query parameters with bracket notation.
104
+
105
+ For example: {"id": "123", "type": "user"} becomes:
106
+ param[id]=123&param[type]=user
107
+
108
+ Args:
109
+ param_value: Dictionary value to format
110
+ parameter_name: Name of the parameter
111
+
112
+ Returns:
113
+ Dictionary with bracketed parameter names as keys
114
+ """
115
+ if not isinstance(param_value, dict):
116
+ logger.warning(
117
+ f"deepObject style parameter '{parameter_name}' expected dict, got {type(param_value)}"
118
+ )
119
+ return {}
120
+
121
+ result = {}
122
+ for key, value in param_value.items():
123
+ # Format as param[key]=value
124
+ bracketed_key = f"{parameter_name}[{key}]"
125
+ result[bracketed_key] = str(value)
126
+
127
+ return result
128
+
129
+
130
  class ParameterInfo(FastMCPBaseModel):
131
  """Represents a single parameter for an HTTP operation in our IR."""
132
 
 
136
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
137
  description: str | None = None
138
  explode: bool | None = None # OpenAPI explode property for array parameters
139
+ style: str | None = None # OpenAPI style property for parameter serialization
140
 
141
 
142
  class RequestBodyInfo(FastMCPBaseModel):
 
188
  "JsonSchema",
189
  "parse_openapi_to_http_routes",
190
  "extract_output_schema_from_responses",
191
+ "format_deep_object_parameter",
192
  ]
193
 
194
  # Type variables for generic parser
 
451
  ):
452
  param_schema_dict["default"] = resolved_media_schema.default
453
 
454
+ # Extract explode and style properties if present
455
  explode = getattr(parameter, "explode", None)
456
+ style = getattr(parameter, "style", None)
457
 
458
  # Create parameter info object
459
  param_info = ParameterInfo(
 
463
  schema=param_schema_dict,
464
  description=parameter.description,
465
  explode=explode,
466
+ style=style,
467
  )
468
  extracted_params.append(param_info)
469
  except Exception as e:
tests/server/openapi/test_deepobject_style.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration test for OpenAPI deepObject style parameter handling.
2
+
3
+ This test verifies that the deepObject style and explode properties are correctly
4
+ parsed from OpenAPI specifications and properly applied during HTTP request serialization.
5
+ """
6
+
7
+ from unittest.mock import AsyncMock, MagicMock
8
+
9
+ import httpx
10
+
11
+ from fastmcp.server.openapi import OpenAPITool
12
+ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
13
+
14
+
15
+ class TestDeepObjectStyle:
16
+ """Test the complete pipeline from OpenAPI spec to HTTP request parameters for deepObject style."""
17
+
18
+ def test_deepobject_style_parsing_from_openapi_spec(self):
19
+ """Test that deepObject style is correctly parsed from OpenAPI specification."""
20
+ # Real OpenAPI spec with style: deepObject and explode: true
21
+ openapi_spec = {
22
+ "openapi": "3.1.0",
23
+ "info": {"title": "Test API", "version": "1.0.0"},
24
+ "paths": {
25
+ "/api/surveys": {
26
+ "get": {
27
+ "operationId": "getSurveys",
28
+ "parameters": [
29
+ {
30
+ "name": "target",
31
+ "in": "query",
32
+ "required": False,
33
+ "style": "deepObject",
34
+ "explode": True,
35
+ "schema": {
36
+ "type": "object",
37
+ "properties": {
38
+ "id": {
39
+ "type": "string",
40
+ "description": "Valid ID for an object",
41
+ },
42
+ "type": {
43
+ "type": "string",
44
+ "enum": ["location", "organisation"],
45
+ "description": "The type of object for given id",
46
+ },
47
+ },
48
+ "required": ["type", "id"],
49
+ },
50
+ }
51
+ ],
52
+ "responses": {
53
+ "200": {
54
+ "description": "Success",
55
+ "content": {
56
+ "application/json": {"schema": {"type": "integer"}}
57
+ },
58
+ }
59
+ },
60
+ }
61
+ }
62
+ },
63
+ }
64
+
65
+ # Parse the spec
66
+ routes = parse_openapi_to_http_routes(openapi_spec)
67
+ route = routes[0]
68
+ parameter = route.parameters[0]
69
+
70
+ # Verify style and explode properties were captured correctly
71
+ assert parameter.name == "target"
72
+ assert parameter.location == "query"
73
+ assert parameter.style == "deepObject", (
74
+ f"Expected style='deepObject', got {parameter.style}"
75
+ )
76
+ assert parameter.explode is True, (
77
+ f"Expected explode=True, got {parameter.explode}"
78
+ )
79
+
80
+ async def test_deepobject_style_request_serialization(self):
81
+ """Test that deepObject style results in bracketed query parameters in HTTP requests.
82
+
83
+ This is the critical integration test that reproduces the GitHub issue.
84
+ """
85
+ # OpenAPI spec matching the GitHub issue example
86
+ openapi_spec = {
87
+ "openapi": "3.1.0",
88
+ "info": {"title": "Test API", "version": "1.0.0"},
89
+ "paths": {
90
+ "/api/surveys": {
91
+ "get": {
92
+ "operationId": "getSurveys",
93
+ "parameters": [
94
+ {
95
+ "name": "target",
96
+ "in": "query",
97
+ "required": False,
98
+ "style": "deepObject",
99
+ "explode": True,
100
+ "schema": {
101
+ "type": "object",
102
+ "properties": {
103
+ "id": {"type": "string"},
104
+ "type": {"type": "string"},
105
+ },
106
+ "required": ["type", "id"],
107
+ },
108
+ }
109
+ ],
110
+ "responses": {"200": {"description": "Success"}},
111
+ }
112
+ }
113
+ },
114
+ }
115
+
116
+ # Parse and create tool
117
+ routes = parse_openapi_to_http_routes(openapi_spec)
118
+ route = routes[0]
119
+
120
+ # Mock HTTP client
121
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
122
+ mock_response = MagicMock()
123
+ mock_response.status_code = 200
124
+ mock_response.json.return_value = {}
125
+ mock_response.raise_for_status.return_value = None
126
+ mock_client.request.return_value = mock_response
127
+
128
+ # Create tool
129
+ tool = OpenAPITool(
130
+ client=mock_client,
131
+ route=route,
132
+ name="getSurveys",
133
+ description="Get surveys",
134
+ parameters={},
135
+ )
136
+
137
+ # Execute tool with object parameter (as it would come from user input)
138
+ await tool.run(
139
+ {"target": {"id": "57dc372a81b610496e8b465e", "type": "organisation"}}
140
+ )
141
+
142
+ # Verify the HTTP request was made with deepObject-style parameters
143
+ mock_client.request.assert_called_once()
144
+ call_kwargs = mock_client.request.call_args.kwargs
145
+
146
+ # Check that params contains bracketed parameters, not JSON string
147
+ params = call_kwargs.get("params", {})
148
+
149
+ # Should have target[id] and target[type] parameters
150
+ assert "target[id]" in params, "target[id] parameter should be present"
151
+ assert "target[type]" in params, "target[type] parameter should be present"
152
+
153
+ # Values should be correctly set
154
+ assert params["target[id]"] == "57dc372a81b610496e8b465e", (
155
+ f"Expected target[id]=57dc372a81b610496e8b465e, got {params.get('target[id]')}"
156
+ )
157
+ assert params["target[type]"] == "organisation", (
158
+ f"Expected target[type]=organisation, got {params.get('target[type]')}"
159
+ )
160
+
161
+ # Should NOT have the original parameter name as JSON
162
+ assert "target" not in params, (
163
+ "Original 'target' parameter should not be present when using deepObject style"
164
+ )
165
+
166
+ async def test_deepobject_style_with_explode_false(self):
167
+ """Test that deepObject style with explode=false falls back to JSON serialization."""
168
+ openapi_spec = {
169
+ "openapi": "3.1.0",
170
+ "info": {"title": "Test API", "version": "1.0.0"},
171
+ "paths": {
172
+ "/api/surveys": {
173
+ "get": {
174
+ "operationId": "getSurveys",
175
+ "parameters": [
176
+ {
177
+ "name": "target",
178
+ "in": "query",
179
+ "style": "deepObject",
180
+ "explode": False, # Non-standard combination
181
+ "schema": {
182
+ "type": "object",
183
+ "properties": {
184
+ "id": {"type": "string"},
185
+ "type": {"type": "string"},
186
+ },
187
+ },
188
+ }
189
+ ],
190
+ "responses": {"200": {"description": "Success"}},
191
+ }
192
+ }
193
+ },
194
+ }
195
+
196
+ routes = parse_openapi_to_http_routes(openapi_spec)
197
+ route = routes[0]
198
+
199
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
200
+ mock_response = MagicMock()
201
+ mock_response.status_code = 200
202
+ mock_response.json.return_value = {}
203
+ mock_response.raise_for_status.return_value = None
204
+ mock_client.request.return_value = mock_response
205
+
206
+ tool = OpenAPITool(
207
+ client=mock_client,
208
+ route=route,
209
+ name="getSurveys",
210
+ description="Get surveys",
211
+ parameters={},
212
+ )
213
+
214
+ await tool.run({"target": {"id": "123", "type": "test"}})
215
+
216
+ mock_client.request.assert_called_once()
217
+ call_kwargs = mock_client.request.call_args.kwargs
218
+
219
+ params = call_kwargs.get("params", {})
220
+
221
+ # Should fall back to JSON serialization
222
+ assert "target" in params, "target parameter should be present"
223
+ assert params["target"] == '{"id": "123", "type": "test"}', (
224
+ f"Expected JSON string fallback, got {params.get('target')}"
225
+ )
226
+
227
+ async def test_non_object_with_deepobject_style(self):
228
+ """Test that non-object parameters with deepObject style are handled gracefully."""
229
+ openapi_spec = {
230
+ "openapi": "3.1.0",
231
+ "info": {"title": "Test API", "version": "1.0.0"},
232
+ "paths": {
233
+ "/api/test": {
234
+ "get": {
235
+ "operationId": "testEndpoint",
236
+ "parameters": [
237
+ {
238
+ "name": "param",
239
+ "in": "query",
240
+ "style": "deepObject",
241
+ "explode": True,
242
+ "schema": {"type": "string"}, # Not an object
243
+ }
244
+ ],
245
+ "responses": {"200": {"description": "Success"}},
246
+ }
247
+ }
248
+ },
249
+ }
250
+
251
+ routes = parse_openapi_to_http_routes(openapi_spec)
252
+ route = routes[0]
253
+
254
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
255
+ mock_response = MagicMock()
256
+ mock_response.status_code = 200
257
+ mock_response.json.return_value = {}
258
+ mock_response.raise_for_status.return_value = None
259
+ mock_client.request.return_value = mock_response
260
+
261
+ tool = OpenAPITool(
262
+ client=mock_client,
263
+ route=route,
264
+ name="testEndpoint",
265
+ description="Test endpoint",
266
+ parameters={},
267
+ )
268
+
269
+ # Pass a string value instead of an object
270
+ await tool.run({"param": "test_value"})
271
+
272
+ mock_client.request.assert_called_once()
273
+ call_kwargs = mock_client.request.call_args.kwargs
274
+
275
+ params = call_kwargs.get("params", {})
276
+
277
+ # Should use the parameter as-is since it's not an object
278
+ assert "param" in params, "param parameter should be present"
279
+ assert params["param"] == "test_value", (
280
+ f"Expected 'test_value', got {params.get('param')}"
281
+ )