Skip to content

Commit 73da864

Browse files
authored
feat: support nested response (#131)
* support nested response * change annotation to English
1 parent 66c2a02 commit 73da864

File tree

12 files changed

+622
-59
lines changed

12 files changed

+622
-59
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,4 @@ cython_debug/
158158
# and can be added to the global gitignore or merged into this file. For a more nuclear
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160160
.idea/
161+
.vscode/

README.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ supporting both synchronous and asynchronous operations.
1818
-**Async/Sync support**: Work with both synchronous and asynchronous HTTP operations
1919
- 🎯 **Decorator-based API**: Clean, intuitive API definition with decorators
2020
- 📝 **OpenAPI/Swagger support**: Generate client code from OpenAPI specifications
21-
- 🛡️ **Automatic validation**: Request/response validation with Pydantic models
21+
- 🛡️ **Mock API Responses**: This is useful for testing or development purposes.
2222
-**Timing context manager**: Use `with client.span(prefix="myapi"):` to log timing for any API call, sync or async
23-
- 🔧 **Flexible configuration**: Easy client configuration with headers, timeouts, and more
2423
- 🔧 **convert api to llm tools**: API2Tools, support `agno`, others coming soon...
24+
- 🌟 **Nested Response Extraction**: Extract and parse deeply nested API responses using JSON path expressions
2525

2626
## TODO
2727

@@ -44,6 +44,7 @@ See the [`example/`](./example/) directory for real-world usage of this library,
4444
- `example_httpx.py`: Async usage with HttpxWebClient
4545
- `example_aiohttp.py`: Async usage with AiohttpWebClient
4646
- `example_tools.py`: How to register and use Agno tools
47+
- `example_nested_response.py`: How to extract data from nested API responses
4748

4849

4950
## Quick Start
@@ -169,6 +170,52 @@ user = client.get_user("123")
169170

170171
```
171172

173+
## Handling Nested API Responses
174+
175+
Many APIs return deeply nested JSON structures. Use the `response_extract_path` parameter to extract and parse specific data from complex API responses:
176+
177+
```python
178+
from typing import List
179+
from pydantic import BaseModel
180+
from pydantic_client import RequestsWebClient, get
181+
182+
class User(BaseModel):
183+
id: str
184+
name: str
185+
email: str
186+
187+
class MyClient(RequestsWebClient):
188+
@get("/users/complex", response_extract_path="$.data.users")
189+
def get_users_nested(self) -> List[User]:
190+
"""
191+
Extracts the users list from a nested response structure
192+
193+
Example response:
194+
{
195+
"status": "success",
196+
"data": {
197+
"users": [
198+
{"id": "1", "name": "Alice", "email": "alice@example.com"},
199+
{"id": "2", "name": "Bob", "email": "bob@example.com"}
200+
],
201+
"total": 2
202+
}
203+
}
204+
"""
205+
pass
206+
207+
@get("/search", response_extract_path="$.results[0]")
208+
def search_first_result(self) -> User:
209+
"""
210+
Get just the first search result from an array
211+
"""
212+
pass
213+
```
214+
215+
The `response_extract_path` parameter defines where to find the data in the response. It supports:
216+
- Array indexing with square brackets: `$.results[0]` -> `User`
217+
- Optional `$` prefix for root object: `$.data.users` -> `list[User]`
218+
172219
## Mock API Responses
173220

174221
You can configure the client to return mock responses instead of making actual API calls. This is useful for testing or development purposes.
@@ -178,10 +225,12 @@ You can configure the client to return mock responses instead of making actual A
178225
```python
179226
from pydantic_client import RequestsWebClient, get
180227

228+
181229
class UserResponse(BaseModel):
182230
id: int
183231
name: str
184232

233+
185234
class MyClient(RequestsWebClient):
186235
@get("/users/{user_id}")
187236
def get_user(self, user_id: int) -> UserResponse:

example/example_nested_path.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from typing import List, Optional
2+
from pydantic import BaseModel
3+
from pydantic_client import RequestsWebClient, get
4+
5+
6+
class User(BaseModel):
7+
id: str
8+
name: str
9+
email: Optional[str] = None
10+
11+
12+
class NestedPathClient(RequestsWebClient):
13+
@get("/users/complex", response_extract_path="$.data.users")
14+
def get_users_nested(self) -> List[User]:
15+
"""
16+
Get users from a nested response structure where the users are in data.users
17+
18+
Example response:
19+
{
20+
"status": "success",
21+
"data": {
22+
"users": [
23+
{"id": "1", "name": "Alice", "email": "alice@example.com"},
24+
{"id": "2", "name": "Bob", "email": "bob@example.com"}
25+
],
26+
"total": 2
27+
}
28+
}
29+
"""
30+
...
31+
32+
@get("/user/{user_id}", response_extract_path="data.user")
33+
def get_user_by_id(self, user_id: str) -> User:
34+
"""
35+
Get a single user from a nested path
36+
37+
Example response:
38+
{
39+
"status": "success",
40+
"data": {
41+
"user": {"id": "1", "name": "Alice", "email": "alice@example.com"}
42+
}
43+
}
44+
"""
45+
...
46+
47+
@get("/search", response_extract_path="$.results[0]")
48+
def search_first_result(self) -> User:
49+
"""
50+
Get just the first search result from an array
51+
52+
Example response:
53+
{
54+
"query": "user search",
55+
"results": [
56+
{"id": "1", "name": "Alice", "email": "alice@example.com"},
57+
{"id": "2", "name": "Bob", "email": "bob@example.com"}
58+
]
59+
}
60+
"""
61+
...
62+
63+
64+
def main():
65+
"""
66+
This is an example of how to use the response_extract_path parameter.
67+
In a real application, you would make actual API calls.
68+
"""
69+
# Create client
70+
client = NestedPathClient(base_url="https://api.example.com")
71+
72+
# Setup mock responses for demonstration
73+
client.set_mock_config(mock_config=[
74+
{
75+
"name": "get_users_nested",
76+
"output": {
77+
"status": "success",
78+
"data": {
79+
"users": [
80+
{"id": "1", "name": "Alice", "email": "alice@example.com"},
81+
{"id": "2", "name": "Bob", "email": "bob@example.com"}
82+
],
83+
"total": 2
84+
}
85+
}
86+
},
87+
{
88+
"name": "get_user_by_id",
89+
"output": {
90+
"status": "success",
91+
"data": {
92+
"user": {"id": "1", "name": "Alice", "email": "alice@example.com"}
93+
}
94+
}
95+
},
96+
{
97+
"name": "search_first_result",
98+
"output": {
99+
"query": "user search",
100+
"results": [
101+
{"id": "1", "name": "Alice", "email": "alice@example.com"},
102+
{"id": "2", "name": "Bob", "email": "bob@example.com"}
103+
]
104+
}
105+
}
106+
])
107+
108+
# Get all users from a nested structure
109+
users = client.get_users_nested()
110+
print(f"Got {len(users)} users:")
111+
for user in users:
112+
print(f" - {user.name} ({user.email})")
113+
114+
# Get a single user by ID
115+
user = client.get_user_by_id("1")
116+
print(f"\nGot user: {user.name} ({user.id})")
117+
118+
# Get just the first search result
119+
first_result = client.search_first_result()
120+
print(f"\nFirst search result: {first_result.name}")
121+
122+
123+
if __name__ == "__main__":
124+
main()

pydantic_client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
"post",
1313
"put",
1414
"patch",
15-
"delete",
15+
"delete"
1616
]

pydantic_client/async_client.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ def __init__(
3030
async def _request(self, request_info: RequestInfo) -> Any:
3131
# Check if there's a mock response for this method
3232
mock_response = self._get_mock_response(request_info)
33-
if mock_response:
33+
if mock_response is not None:
3434
return mock_response
3535

3636
import aiohttp
3737
request_params = self.dump_request_params(request_info)
3838
response_model = request_params.pop("response_model")
39+
extract_path = request_params.pop("response_extract_path", None) # Get response extraction path parameter
3940

4041
request_params = self.before_request(request_params)
4142

@@ -49,9 +50,16 @@ async def _request(self, request_info: RequestInfo) -> Any:
4950
return await response.text()
5051
elif response_model is bytes:
5152
return await response.content.read()
52-
elif not response_model:
53-
return await response.json()
54-
return response_model.model_validate(await response.json(), from_attributes=True)
53+
54+
json_data = await response.json()
55+
if extract_path:
56+
return self._extract_nested_data(json_data, extract_path, response_model)
57+
elif not response_model or response_model is dict or getattr(response_model, '__module__', None) == 'inspect':
58+
return json_data
59+
elif hasattr(response_model, 'model_validate'):
60+
return response_model.model_validate(json_data, from_attributes=True)
61+
else:
62+
return json_data
5563

5664

5765
class HttpxWebClient(BaseWebClient):
@@ -73,13 +81,14 @@ def __init__(
7381
async def _request(self, request_info: RequestInfo) -> Any:
7482
# Check if there's a mock response for this method
7583
mock_response = self._get_mock_response(request_info)
76-
if mock_response:
84+
if mock_response is not None:
7785
return mock_response
7886

79-
# 没有mock数据,继续进行正常请求
87+
# No mock data, continue with the normal request
8088
import httpx
8189
request_params = self.dump_request_params(request_info)
8290
response_model = request_params.pop("response_model")
91+
extract_path = request_params.pop("response_extract_path", None) # Get response extraction path parameter
8392

8493
request_params = self.before_request(request_params)
8594

@@ -91,6 +100,13 @@ async def _request(self, request_info: RequestInfo) -> Any:
91100
return response.text
92101
elif response_model is bytes:
93102
return response.content
94-
elif not response_model:
95-
return response.json()
96-
return response_model.model_validate(response.json(), from_attributes=True)
103+
104+
json_data = response.json()
105+
if extract_path:
106+
return self._extract_nested_data(json_data, extract_path, response_model)
107+
elif not response_model or response_model is dict or getattr(response_model, '__module__', None) == 'inspect':
108+
return json_data
109+
elif hasattr(response_model, 'model_validate'):
110+
return response_model.model_validate(json_data, from_attributes=True)
111+
else:
112+
return json_data

pydantic_client/base.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import logging
44
import time
55
import statsd
6+
import re
67

78
from abc import ABC, abstractmethod
8-
from typing import Any, Dict, Optional, TypeVar, List
9+
from typing import Any, Dict, Optional, TypeVar, List, Type, get_origin, get_args
910

1011
from pydantic import BaseModel
1112
from .schema import RequestInfo
@@ -92,6 +93,7 @@ def dump_request_params(self, request_info: RequestInfo) -> Dict[str, Any]:
9293
request_params["headers"] = request_headers
9394
request_params["url"] = url
9495
request_params.pop("function_name", None)
96+
# response_extract_path 会在 _request 方法中处理,所以保留
9597
return request_params
9698

9799
def set_mock_config(
@@ -146,17 +148,23 @@ def set_mock_config(
146148
def _get_mock_response(self, request_info: RequestInfo) -> Optional[Any]:
147149
"""Get mock response for a method if available in mock config"""
148150
if not self._mock_config:
149-
return
151+
return None
150152

151153
name = request_info.function_name
152154

153155
if name not in self._mock_config:
154156
logger.warning(f"No mock found for method: {name}")
155-
return
157+
return None
156158

157159
mock_response = self._mock_config[name]
158160
response_model = request_info.response_model
159-
if response_model and not isinstance(response_model, type) or response_model in (str, bytes):
161+
162+
163+
extract_path = request_info.response_extract_path
164+
165+
if extract_path:
166+
return self._extract_nested_data(mock_response, extract_path, response_model)
167+
elif response_model and not isinstance(response_model, type) or response_model in (str, bytes):
160168
return mock_response
161169
elif response_model:
162170
return response_model.model_validate(mock_response, from_attributes=True)
@@ -165,7 +173,50 @@ def _get_mock_response(self, request_info: RequestInfo) -> Optional[Any]:
165173
@abstractmethod
166174
def _request(self, request_info: RequestInfo) -> Any:
167175
...
168-
176+
177+
def _extract_nested_data(self, data: Dict[str, Any], path: str, model_type: Type) -> Any:
178+
"""
179+
Extract and parse data from nested response data
180+
181+
Args:
182+
data: JSON response data
183+
path: JSON path expression, e.g. "$.data.user" or "$.data.items[0]"
184+
model_type: Pydantic model type for parsing the extracted data
185+
"""
186+
# Preprocess path, remove $ prefix
187+
if path.startswith('$'):
188+
path = path[2:].lstrip('.')
189+
190+
# Extract all components from the path
191+
path_components = re.split(r'\.|\[|\]', path)
192+
path_components = [p for p in path_components if p]
193+
194+
current = data
195+
for component in path_components:
196+
if component.isdigit():
197+
idx = int(component)
198+
if isinstance(current, list) and 0 <= idx < len(current):
199+
current = current[idx]
200+
else:
201+
return None
202+
elif isinstance(current, dict):
203+
current = current.get(component)
204+
if current is None:
205+
return None
206+
else:
207+
return None
208+
209+
if current is not None:
210+
# Handle list types
211+
origin = get_origin(model_type)
212+
213+
if origin is list or origin is List:
214+
item_type = get_args(model_type)[0]
215+
if isinstance(current, list):
216+
return [item_type.model_validate(item) for item in current]
217+
elif hasattr(model_type, 'model_validate'):
218+
return model_type.model_validate(current)
219+
return current
169220

170221
def register_agno_tools(self, agent):
171222
"""

0 commit comments

Comments
 (0)