kollabor 0.4.9__py3-none-any.whl → 0.4.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,2150 @@
|
|
|
1
|
+
<!-- API Integration skill - integrate with external APIs reliably -->
|
|
2
|
+
|
|
3
|
+
api-integration mode: RELIABLE EXTERNAL SERVICE CONNECTIONS
|
|
4
|
+
|
|
5
|
+
when this skill is active, you follow disciplined API integration practices.
|
|
6
|
+
this is a comprehensive guide to integrating with REST and GraphQL APIs.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PHASE 0: ENVIRONMENT PREREQUISITES VERIFICATION
|
|
10
|
+
|
|
11
|
+
before integrating ANY external API, verify your environment is ready.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
check http client library
|
|
15
|
+
|
|
16
|
+
<terminal>python -c "import requests; print(requests.__version__)"</terminal>
|
|
17
|
+
|
|
18
|
+
if requests not installed:
|
|
19
|
+
<terminal>pip install requests</terminal>
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
check async http client
|
|
23
|
+
|
|
24
|
+
<terminal>python -c "import httpx; print(httpx.__version__)"</terminal>
|
|
25
|
+
|
|
26
|
+
if httpx not installed:
|
|
27
|
+
<terminal>pip install httpx</terminal>
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
verify api credentials exist
|
|
31
|
+
|
|
32
|
+
<terminal>ls -la .env 2>/dev/null || echo "no .env file"</terminal>
|
|
33
|
+
|
|
34
|
+
<terminal>echo $API_KEY 2>/dev/null || echo "API_KEY not set"</terminal>
|
|
35
|
+
|
|
36
|
+
if credentials missing:
|
|
37
|
+
<create>
|
|
38
|
+
<file>.env.example</file>
|
|
39
|
+
<content>
|
|
40
|
+
# API Credentials
|
|
41
|
+
API_KEY=your_api_key_here
|
|
42
|
+
API_SECRET=your_api_secret_here
|
|
43
|
+
API_BASE_URL=https://api.example.com
|
|
44
|
+
</content>
|
|
45
|
+
</create>
|
|
46
|
+
|
|
47
|
+
remind user to create .env from .env.example
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
check for environment variable loader
|
|
51
|
+
|
|
52
|
+
<terminal>python -c "import dotenv; print('python-dotenv installed')" 2>/dev/null || echo "need python-dotenv"</terminal>
|
|
53
|
+
|
|
54
|
+
if not installed:
|
|
55
|
+
<terminal>pip install python-dotenv</terminal>
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
check project structure for api code
|
|
59
|
+
|
|
60
|
+
<terminal>ls -la src/api/ 2>/dev/null || ls -la core/api/ 2>/dev/null || echo "no api directory"</terminal>
|
|
61
|
+
|
|
62
|
+
<terminal>find . -name "*client*.py" -type f | grep -v __pycache__ | head -5</terminal>
|
|
63
|
+
|
|
64
|
+
understand existing api patterns before adding new integrations.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
verify request/response validation tools
|
|
68
|
+
|
|
69
|
+
<terminal>python -c "import pydantic; print('pydantic available')" 2>/dev/null || echo "pydantic not installed"</terminal>
|
|
70
|
+
|
|
71
|
+
if not installed:
|
|
72
|
+
<terminal>pip install pydantic</terminal>
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
PHASE 1: API INTEGRATION FUNDAMENTALS
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
understand the api contract
|
|
79
|
+
|
|
80
|
+
before writing code, gather this information:
|
|
81
|
+
|
|
82
|
+
[ ] base URL (e.g., https://api.example.com/v1)
|
|
83
|
+
[ ] authentication method (API key, OAuth, JWT)
|
|
84
|
+
[ ] rate limits (requests per minute/hour)
|
|
85
|
+
[ ] available endpoints
|
|
86
|
+
[ ] request/response formats
|
|
87
|
+
[ ] error response format
|
|
88
|
+
[ ] pagination style
|
|
89
|
+
[ ] webhooks or streaming support
|
|
90
|
+
|
|
91
|
+
read the documentation. bookmark the reference.
|
|
92
|
+
save the openapi spec if available.
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
basic rest client structure
|
|
96
|
+
|
|
97
|
+
<create>
|
|
98
|
+
<file>src/api/base_client.py</file>
|
|
99
|
+
<content>
|
|
100
|
+
"""Base API client with common functionality."""
|
|
101
|
+
from typing import Any, Dict, Optional
|
|
102
|
+
import requests
|
|
103
|
+
from requests.adapters import HTTPAdapter
|
|
104
|
+
from urllib3.util.retry import Retry
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BaseAPIClient:
|
|
108
|
+
"""Base class for API clients."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
base_url: str,
|
|
113
|
+
api_key: Optional[str] = None,
|
|
114
|
+
timeout: int = 30,
|
|
115
|
+
max_retries: int = 3
|
|
116
|
+
):
|
|
117
|
+
self.base_url = base_url.rstrip("/")
|
|
118
|
+
self.api_key = api_key
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
|
|
121
|
+
# configure session with retry logic
|
|
122
|
+
self.session = requests.Session()
|
|
123
|
+
retry_strategy = Retry(
|
|
124
|
+
total=max_retries,
|
|
125
|
+
backoff_factor=1,
|
|
126
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
127
|
+
allowed_methods=["GET", "POST", "PUT", "DELETE", "PATCH"]
|
|
128
|
+
)
|
|
129
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
130
|
+
self.session.mount("http://", adapter)
|
|
131
|
+
self.session.mount("https://", adapter)
|
|
132
|
+
|
|
133
|
+
def _build_url(self, path: str) -> str:
|
|
134
|
+
"""Build full URL from path."""
|
|
135
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
136
|
+
|
|
137
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
138
|
+
"""Build default headers."""
|
|
139
|
+
headers = {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
"Accept": "application/json"
|
|
142
|
+
}
|
|
143
|
+
if self.api_key:
|
|
144
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
145
|
+
return headers
|
|
146
|
+
</content>
|
|
147
|
+
</create>
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async rest client with httpx
|
|
151
|
+
|
|
152
|
+
<create>
|
|
153
|
+
<file>src/api/async_client.py</file>
|
|
154
|
+
<content>
|
|
155
|
+
"""Async API client using httpx."""
|
|
156
|
+
from typing import Any, Dict, Optional
|
|
157
|
+
import httpx
|
|
158
|
+
import asyncio
|
|
159
|
+
from httpx import AsyncClient, Response, TimeoutException
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AsyncAPIClient:
|
|
163
|
+
"""Async API client for high-performance integration."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
base_url: str,
|
|
168
|
+
api_key: Optional[str] = None,
|
|
169
|
+
timeout: float = 30.0,
|
|
170
|
+
limits: Optional[httpx.Limits] = None
|
|
171
|
+
):
|
|
172
|
+
self.base_url = base_url.rstrip("/")
|
|
173
|
+
self.api_key = api_key
|
|
174
|
+
self.timeout = timeout
|
|
175
|
+
|
|
176
|
+
# configure connection limits
|
|
177
|
+
if limits is None:
|
|
178
|
+
limits = httpx.Limits(
|
|
179
|
+
max_keepalive_connections=20,
|
|
180
|
+
max_connections=100,
|
|
181
|
+
keepalive_expiry=5.0
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self._client: Optional[AsyncClient] = None
|
|
185
|
+
self._limits = limits
|
|
186
|
+
|
|
187
|
+
async def __aenter__(self):
|
|
188
|
+
"""Enter context manager."""
|
|
189
|
+
await self.connect()
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
193
|
+
"""Exit context manager."""
|
|
194
|
+
await self.close()
|
|
195
|
+
|
|
196
|
+
async def connect(self):
|
|
197
|
+
"""Initialize the async client."""
|
|
198
|
+
if self._client is None:
|
|
199
|
+
headers = {
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
"Accept": "application/json"
|
|
202
|
+
}
|
|
203
|
+
if self.api_key:
|
|
204
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
205
|
+
|
|
206
|
+
self._client = AsyncClient(
|
|
207
|
+
base_url=self.base_url,
|
|
208
|
+
headers=headers,
|
|
209
|
+
timeout=self.timeout,
|
|
210
|
+
limits=self._limits
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def close(self):
|
|
214
|
+
"""Close the async client."""
|
|
215
|
+
if self._client:
|
|
216
|
+
await self._client.aclose()
|
|
217
|
+
self._client = None
|
|
218
|
+
|
|
219
|
+
def _ensure_connected(self):
|
|
220
|
+
"""Ensure client is connected."""
|
|
221
|
+
if self._client is None:
|
|
222
|
+
raise RuntimeError("Client not connected. Use async with or call connect().")
|
|
223
|
+
</content>
|
|
224
|
+
</create>
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
PHASE 2: AUTHENTICATION PATTERNS
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
api key authentication
|
|
231
|
+
|
|
232
|
+
simplest form - key in header or query param:
|
|
233
|
+
|
|
234
|
+
<read><file>src/api/base_client.py</file></read>
|
|
235
|
+
|
|
236
|
+
<edit>
|
|
237
|
+
<file>src/api/base_client.py</file>
|
|
238
|
+
<find>
|
|
239
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
240
|
+
"""Build default headers."""
|
|
241
|
+
headers = {
|
|
242
|
+
"Content-Type": "application/json",
|
|
243
|
+
"Accept": "application/json"
|
|
244
|
+
}
|
|
245
|
+
if self.api_key:
|
|
246
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
247
|
+
return headers
|
|
248
|
+
</find>
|
|
249
|
+
<replace>
|
|
250
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
251
|
+
"""Build default headers."""
|
|
252
|
+
headers = {
|
|
253
|
+
"Content-Type": "application/json",
|
|
254
|
+
"Accept": "application/json"
|
|
255
|
+
}
|
|
256
|
+
if self.api_key:
|
|
257
|
+
# common patterns: Bearer token, API key, or custom header
|
|
258
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
259
|
+
# alternative: headers["X-API-Key"] = self.api_key
|
|
260
|
+
return headers
|
|
261
|
+
</replace>
|
|
262
|
+
</edit>
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
basic auth (username/password)
|
|
266
|
+
|
|
267
|
+
import requests
|
|
268
|
+
from requests.auth import HTTPBasicAuth
|
|
269
|
+
|
|
270
|
+
response = requests.get(
|
|
271
|
+
"https://api.example.com/endpoint",
|
|
272
|
+
auth=HTTPBasicAuth("username", "password")
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
oauth2 client credentials flow
|
|
277
|
+
|
|
278
|
+
<create>
|
|
279
|
+
<file>src/api/oauth_client.py</file>
|
|
280
|
+
<content>
|
|
281
|
+
"""OAuth2 client credentials authentication."""
|
|
282
|
+
from typing import Optional
|
|
283
|
+
import time
|
|
284
|
+
import requests
|
|
285
|
+
from dataclasses import dataclass
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class TokenResponse:
|
|
290
|
+
"""OAuth token response."""
|
|
291
|
+
access_token: str
|
|
292
|
+
token_type: str
|
|
293
|
+
expires_in: int
|
|
294
|
+
refresh_token: Optional[str] = None
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def expires_at(self) -> float:
|
|
298
|
+
"""Calculate expiration timestamp."""
|
|
299
|
+
return time.time() + self.expires_in - 60 # 1 minute buffer
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class OAuth2Client:
|
|
303
|
+
"""OAuth2 client with automatic token refresh."""
|
|
304
|
+
|
|
305
|
+
def __init__(
|
|
306
|
+
self,
|
|
307
|
+
token_url: str,
|
|
308
|
+
client_id: str,
|
|
309
|
+
client_secret: str,
|
|
310
|
+
scope: Optional[str] = None
|
|
311
|
+
):
|
|
312
|
+
self.token_url = token_url
|
|
313
|
+
self.client_id = client_id
|
|
314
|
+
self.client_secret = client_secret
|
|
315
|
+
self.scope = scope
|
|
316
|
+
self._token: Optional[TokenResponse] = None
|
|
317
|
+
|
|
318
|
+
def get_token(self) -> str:
|
|
319
|
+
"""Get valid access token, refreshing if needed."""
|
|
320
|
+
if self._token is None or time.time() >= self._token.expires_at:
|
|
321
|
+
self._fetch_token()
|
|
322
|
+
return self._token.access_token
|
|
323
|
+
|
|
324
|
+
def _fetch_token(self):
|
|
325
|
+
"""Fetch new token from auth server."""
|
|
326
|
+
data = {
|
|
327
|
+
"grant_type": "client_credentials",
|
|
328
|
+
"client_id": self.client_id,
|
|
329
|
+
"client_secret": self.client_secret
|
|
330
|
+
}
|
|
331
|
+
if self.scope:
|
|
332
|
+
data["scope"] = self.scope
|
|
333
|
+
|
|
334
|
+
response = requests.post(self.token_url, data=data)
|
|
335
|
+
response.raise_for_status()
|
|
336
|
+
|
|
337
|
+
token_data = response.json()
|
|
338
|
+
self._token = TokenResponse(
|
|
339
|
+
access_token=token_data["access_token"],
|
|
340
|
+
token_type=token_data["token_type"],
|
|
341
|
+
expires_in=token_data["expires_in"],
|
|
342
|
+
refresh_token=token_data.get("refresh_token")
|
|
343
|
+
)
|
|
344
|
+
</content>
|
|
345
|
+
</create>
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
oauth2 authorization code flow
|
|
349
|
+
|
|
350
|
+
<create>
|
|
351
|
+
<file>src/api/auth_code_client.py</file>
|
|
352
|
+
<content>
|
|
353
|
+
"""OAuth2 authorization code flow for user authentication."""
|
|
354
|
+
from typing import Optional
|
|
355
|
+
import uuid
|
|
356
|
+
from urllib.parse import urlencode
|
|
357
|
+
import webbrowser
|
|
358
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
359
|
+
import requests
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class OAuth2AuthCodeFlow:
|
|
363
|
+
"""Handle OAuth2 authorization code flow with local callback."""
|
|
364
|
+
|
|
365
|
+
def __init__(
|
|
366
|
+
self,
|
|
367
|
+
auth_url: str,
|
|
368
|
+
token_url: str,
|
|
369
|
+
client_id: str,
|
|
370
|
+
client_secret: str,
|
|
371
|
+
redirect_uri: str = "http://localhost:8080/callback",
|
|
372
|
+
scope: str = "openid profile email"
|
|
373
|
+
):
|
|
374
|
+
self.auth_url = auth_url
|
|
375
|
+
self.token_url = token_url
|
|
376
|
+
self.client_id = client_id
|
|
377
|
+
self.client_secret = client_secret
|
|
378
|
+
self.redirect_uri = redirect_uri
|
|
379
|
+
self.scope = scope
|
|
380
|
+
self.state = str(uuid.uuid4())
|
|
381
|
+
self.auth_code: Optional[str] = None
|
|
382
|
+
|
|
383
|
+
def get_auth_url(self) -> str:
|
|
384
|
+
"""Generate authorization URL."""
|
|
385
|
+
params = {
|
|
386
|
+
"response_type": "code",
|
|
387
|
+
"client_id": self.client_id,
|
|
388
|
+
"redirect_uri": self.redirect_uri,
|
|
389
|
+
"scope": self.scope,
|
|
390
|
+
"state": self.state
|
|
391
|
+
}
|
|
392
|
+
return f"{self.auth_url}?{urlencode(params)}"
|
|
393
|
+
|
|
394
|
+
def start_flow(self):
|
|
395
|
+
"""Start authorization flow by opening browser."""
|
|
396
|
+
auth_url = self.get_auth_url()
|
|
397
|
+
print(f"Opening browser for authorization: {auth_url}")
|
|
398
|
+
webbrowser.open(auth_url)
|
|
399
|
+
|
|
400
|
+
# start local server to handle callback
|
|
401
|
+
self._start_callback_server()
|
|
402
|
+
|
|
403
|
+
def _start_callback_server(self):
|
|
404
|
+
"""Start local HTTP server for callback."""
|
|
405
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
406
|
+
def __init__(self, parent, *args, **kwargs):
|
|
407
|
+
self.parent = parent
|
|
408
|
+
super().__init__(*args, **kwargs)
|
|
409
|
+
|
|
410
|
+
def do_GET(self):
|
|
411
|
+
if self.path.startswith("/callback"):
|
|
412
|
+
# parse query params
|
|
413
|
+
from urllib.parse import urlparse, parse_qs
|
|
414
|
+
query = parse_qs(urlparse(self.path).query)
|
|
415
|
+
|
|
416
|
+
code = query.get("code", [None])[0]
|
|
417
|
+
state = query.get("state", [None])[0]
|
|
418
|
+
|
|
419
|
+
if state == self.parent.state and code:
|
|
420
|
+
self.parent.auth_code = code
|
|
421
|
+
self.send_response(200)
|
|
422
|
+
self.end_headers()
|
|
423
|
+
self.wfile.write(b"Authorization successful! You can close this window.")
|
|
424
|
+
else:
|
|
425
|
+
self.send_response(400)
|
|
426
|
+
self.end_headers()
|
|
427
|
+
self.wfile.write(b"Authorization failed!")
|
|
428
|
+
|
|
429
|
+
def log_message(self, format, *args):
|
|
430
|
+
pass # suppress logs
|
|
431
|
+
|
|
432
|
+
server = HTTPServer(("localhost", 8080), lambda *args, **kwargs: CallbackHandler(self, *args, **kwargs))
|
|
433
|
+
print("Waiting for authorization callback on http://localhost:8080")
|
|
434
|
+
server.handle_request()
|
|
435
|
+
|
|
436
|
+
def exchange_code_for_token(self) -> dict:
|
|
437
|
+
"""Exchange authorization code for access token."""
|
|
438
|
+
if not self.auth_code:
|
|
439
|
+
raise RuntimeError("No authorization code received")
|
|
440
|
+
|
|
441
|
+
data = {
|
|
442
|
+
"grant_type": "authorization_code",
|
|
443
|
+
"code": self.auth_code,
|
|
444
|
+
"redirect_uri": self.redirect_uri,
|
|
445
|
+
"client_id": self.client_id,
|
|
446
|
+
"client_secret": self.client_secret
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
response = requests.post(self.token_url, data=data)
|
|
450
|
+
response.raise_for_status()
|
|
451
|
+
return response.json()
|
|
452
|
+
</content>
|
|
453
|
+
</create>
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
jwt authentication
|
|
457
|
+
|
|
458
|
+
<create>
|
|
459
|
+
<file>src/api/jwt_auth.py</file>
|
|
460
|
+
<content>
|
|
461
|
+
"""JWT authentication for API clients."""
|
|
462
|
+
from typing import Dict, Optional
|
|
463
|
+
import time
|
|
464
|
+
import jwt
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class JWTAuth:
|
|
468
|
+
"""JWT token generation and validation."""
|
|
469
|
+
|
|
470
|
+
def __init__(
|
|
471
|
+
self,
|
|
472
|
+
secret_key: str,
|
|
473
|
+
algorithm: str = "HS256",
|
|
474
|
+
issuer: Optional[str] = None,
|
|
475
|
+
audience: Optional[str] = None
|
|
476
|
+
):
|
|
477
|
+
self.secret_key = secret_key
|
|
478
|
+
self.algorithm = algorithm
|
|
479
|
+
self.issuer = issuer
|
|
480
|
+
self.audience = audience
|
|
481
|
+
|
|
482
|
+
def generate_token(
|
|
483
|
+
self,
|
|
484
|
+
subject: str,
|
|
485
|
+
payload: Optional[Dict] = None,
|
|
486
|
+
expires_in: int = 3600
|
|
487
|
+
) -> str:
|
|
488
|
+
"""Generate a JWT token."""
|
|
489
|
+
now = int(time.time())
|
|
490
|
+
|
|
491
|
+
jwt_payload = {
|
|
492
|
+
"sub": subject,
|
|
493
|
+
"iat": now,
|
|
494
|
+
"exp": now + expires_in,
|
|
495
|
+
**(payload or {})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if self.issuer:
|
|
499
|
+
jwt_payload["iss"] = self.issuer
|
|
500
|
+
if self.audience:
|
|
501
|
+
jwt_payload["aud"] = self.audience
|
|
502
|
+
|
|
503
|
+
return jwt.encode(jwt_payload, self.secret_key, algorithm=self.algorithm)
|
|
504
|
+
|
|
505
|
+
def validate_token(self, token: str) -> Dict:
|
|
506
|
+
"""Validate and decode a JWT token."""
|
|
507
|
+
try:
|
|
508
|
+
return jwt.decode(
|
|
509
|
+
token,
|
|
510
|
+
self.secret_key,
|
|
511
|
+
algorithms=[self.algorithm],
|
|
512
|
+
issuer=self.issuer,
|
|
513
|
+
audience=self.audience
|
|
514
|
+
)
|
|
515
|
+
except jwt.ExpiredSignatureError:
|
|
516
|
+
raise ValueError("Token has expired")
|
|
517
|
+
except jwt.InvalidTokenError as e:
|
|
518
|
+
raise ValueError(f"Invalid token: {e}")
|
|
519
|
+
</content>
|
|
520
|
+
</create>
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
PHASE 3: MAKING REQUESTS
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
get requests
|
|
527
|
+
|
|
528
|
+
<read><file>src/api/base_client.py</file></read>
|
|
529
|
+
|
|
530
|
+
<edit>
|
|
531
|
+
<file>src/api/base_client.py</file>
|
|
532
|
+
<find>
|
|
533
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
534
|
+
"""Build default headers."""
|
|
535
|
+
headers = {
|
|
536
|
+
"Content-Type": "application/json",
|
|
537
|
+
"Accept": "application/json"
|
|
538
|
+
}
|
|
539
|
+
if self.api_key:
|
|
540
|
+
# common patterns: Bearer token, API key, or custom header
|
|
541
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
542
|
+
# alternative: headers["X-API-Key"] = self.api_key
|
|
543
|
+
return headers
|
|
544
|
+
</find>
|
|
545
|
+
<replace>
|
|
546
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
547
|
+
"""Build default headers."""
|
|
548
|
+
headers = {
|
|
549
|
+
"Content-Type": "application/json",
|
|
550
|
+
"Accept": "application/json"
|
|
551
|
+
}
|
|
552
|
+
if self.api_key:
|
|
553
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
554
|
+
return headers
|
|
555
|
+
|
|
556
|
+
def get(self, path: str, params: Optional[Dict] = None) -> Response:
|
|
557
|
+
"""Make GET request."""
|
|
558
|
+
url = self._build_url(path)
|
|
559
|
+
response = self.session.get(
|
|
560
|
+
url,
|
|
561
|
+
headers=self._get_headers(),
|
|
562
|
+
params=params,
|
|
563
|
+
timeout=self.timeout
|
|
564
|
+
)
|
|
565
|
+
response.raise_for_status()
|
|
566
|
+
return response
|
|
567
|
+
|
|
568
|
+
def post(self, path: str, data: Optional[Dict] = None, json: Optional[Dict] = None) -> Response:
|
|
569
|
+
"""Make POST request."""
|
|
570
|
+
url = self._build_url(path)
|
|
571
|
+
response = self.session.post(
|
|
572
|
+
url,
|
|
573
|
+
headers=self._get_headers(),
|
|
574
|
+
data=data,
|
|
575
|
+
json=json,
|
|
576
|
+
timeout=self.timeout
|
|
577
|
+
)
|
|
578
|
+
response.raise_for_status()
|
|
579
|
+
return response
|
|
580
|
+
|
|
581
|
+
def put(self, path: str, data: Optional[Dict] = None, json: Optional[Dict] = None) -> Response:
|
|
582
|
+
"""Make PUT request."""
|
|
583
|
+
url = self._build_url(path)
|
|
584
|
+
response = self.session.put(
|
|
585
|
+
url,
|
|
586
|
+
headers=self._get_headers(),
|
|
587
|
+
data=data,
|
|
588
|
+
json=json,
|
|
589
|
+
timeout=self.timeout
|
|
590
|
+
)
|
|
591
|
+
response.raise_for_status()
|
|
592
|
+
return response
|
|
593
|
+
|
|
594
|
+
def patch(self, path: str, data: Optional[Dict] = None, json: Optional[Dict] = None) -> Response:
|
|
595
|
+
"""Make PATCH request."""
|
|
596
|
+
url = self._build_url(path)
|
|
597
|
+
response = self.session.patch(
|
|
598
|
+
url,
|
|
599
|
+
headers=self._get_headers(),
|
|
600
|
+
data=data,
|
|
601
|
+
json=json,
|
|
602
|
+
timeout=self.timeout
|
|
603
|
+
)
|
|
604
|
+
response.raise_for_status()
|
|
605
|
+
return response
|
|
606
|
+
|
|
607
|
+
def delete(self, path: str) -> Response:
|
|
608
|
+
"""Make DELETE request."""
|
|
609
|
+
url = self._build_url(path)
|
|
610
|
+
response = self.session.delete(
|
|
611
|
+
url,
|
|
612
|
+
headers=self._get_headers(),
|
|
613
|
+
timeout=self.timeout
|
|
614
|
+
)
|
|
615
|
+
response.raise_for_status()
|
|
616
|
+
return response
|
|
617
|
+
</replace>
|
|
618
|
+
</edit>
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
add missing imports
|
|
622
|
+
<read><file>src/api/base_client.py</file></read>
|
|
623
|
+
|
|
624
|
+
<edit>
|
|
625
|
+
<file>src/api/base_client.py</file>
|
|
626
|
+
<find>
|
|
627
|
+
"""Base API client with common functionality."""
|
|
628
|
+
from typing import Any, Dict, Optional
|
|
629
|
+
import requests
|
|
630
|
+
from requests.adapters import HTTPAdapter
|
|
631
|
+
from urllib3.util.retry import Retry
|
|
632
|
+
</find>
|
|
633
|
+
<replace>
|
|
634
|
+
"""Base API client with common functionality."""
|
|
635
|
+
from typing import Any, Dict, Optional
|
|
636
|
+
import requests
|
|
637
|
+
from requests.adapters import HTTPAdapter
|
|
638
|
+
from requests.models import Response
|
|
639
|
+
from urllib3.util.retry import Retry
|
|
640
|
+
</replace>
|
|
641
|
+
</edit>
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async requests
|
|
645
|
+
|
|
646
|
+
<read><file>src/api/async_client.py</file></read>
|
|
647
|
+
|
|
648
|
+
<edit>
|
|
649
|
+
<file>src/api/async_client.py</file>
|
|
650
|
+
<find>
|
|
651
|
+
def _ensure_connected(self):
|
|
652
|
+
"""Ensure client is connected."""
|
|
653
|
+
if self._client is None:
|
|
654
|
+
raise RuntimeError("Client not connected. Use async with or call connect().")
|
|
655
|
+
</find>
|
|
656
|
+
<replace>
|
|
657
|
+
def _ensure_connected(self):
|
|
658
|
+
"""Ensure client is connected."""
|
|
659
|
+
if self._client is None:
|
|
660
|
+
raise RuntimeError("Client not connected. Use async with or call connect().")
|
|
661
|
+
|
|
662
|
+
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Response:
|
|
663
|
+
"""Make async GET request."""
|
|
664
|
+
self._ensure_connected()
|
|
665
|
+
return await self._client.get(path, params=params)
|
|
666
|
+
|
|
667
|
+
async def post(
|
|
668
|
+
self,
|
|
669
|
+
path: str,
|
|
670
|
+
data: Optional[Dict[str, Any]] = None,
|
|
671
|
+
json: Optional[Dict[str, Any]] = None
|
|
672
|
+
) -> Response:
|
|
673
|
+
"""Make async POST request."""
|
|
674
|
+
self._ensure_connected()
|
|
675
|
+
return await self._client.post(path, data=data, json=json)
|
|
676
|
+
|
|
677
|
+
async def put(
|
|
678
|
+
self,
|
|
679
|
+
path: str,
|
|
680
|
+
data: Optional[Dict[str, Any]] = None,
|
|
681
|
+
json: Optional[Dict[str, Any]] = None
|
|
682
|
+
) -> Response:
|
|
683
|
+
"""Make async PUT request."""
|
|
684
|
+
self._ensure_connected()
|
|
685
|
+
return await self._client.put(path, data=data, json=json)
|
|
686
|
+
|
|
687
|
+
async def patch(
|
|
688
|
+
self,
|
|
689
|
+
path: str,
|
|
690
|
+
data: Optional[Dict[str, Any]] = None,
|
|
691
|
+
json: Optional[Dict[str, Any]] = None
|
|
692
|
+
) -> Response:
|
|
693
|
+
"""Make async PATCH request."""
|
|
694
|
+
self._ensure_connected()
|
|
695
|
+
return await self._client.patch(path, data=data, json=json)
|
|
696
|
+
|
|
697
|
+
async def delete(self, path: str) -> Response:
|
|
698
|
+
"""Make async DELETE request."""
|
|
699
|
+
self._ensure_connected()
|
|
700
|
+
return await self._client.delete(path)
|
|
701
|
+
</replace>
|
|
702
|
+
</edit>
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
PHASE 4: ERROR HANDLING
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
custom exception hierarchy
|
|
709
|
+
|
|
710
|
+
<create>
|
|
711
|
+
<file>src/api/exceptions.py</file>
|
|
712
|
+
<content>
|
|
713
|
+
"""API exception hierarchy for specific error handling."""
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class APIError(Exception):
|
|
717
|
+
"""Base exception for all API errors."""
|
|
718
|
+
|
|
719
|
+
def __init__(
|
|
720
|
+
self,
|
|
721
|
+
message: str,
|
|
722
|
+
status_code: Optional[int] = None,
|
|
723
|
+
response_data: Optional[dict] = None
|
|
724
|
+
):
|
|
725
|
+
super().__init__(message)
|
|
726
|
+
self.status_code = status_code
|
|
727
|
+
self.response_data = response_data or {}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class AuthenticationError(APIError):
|
|
731
|
+
"""Authentication failed - invalid credentials."""
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class AuthorizationError(APIError):
|
|
736
|
+
"""Authorization failed - insufficient permissions."""
|
|
737
|
+
pass
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class RateLimitError(APIError):
|
|
741
|
+
"""Rate limit exceeded."""
|
|
742
|
+
|
|
743
|
+
def __init__(
|
|
744
|
+
self,
|
|
745
|
+
message: str,
|
|
746
|
+
retry_after: Optional[int] = None,
|
|
747
|
+
response_data: Optional[dict] = None
|
|
748
|
+
):
|
|
749
|
+
super().__init__(message, status_code=429, response_data=response_data)
|
|
750
|
+
self.retry_after = retry_after
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class ValidationError(APIError):
|
|
754
|
+
"""Request validation failed (400)."""
|
|
755
|
+
pass
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
class NotFoundError(APIError):
|
|
759
|
+
"""Resource not found (404)."""
|
|
760
|
+
pass
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
class ConflictError(APIError):
|
|
764
|
+
"""Resource conflict (409)."""
|
|
765
|
+
pass
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class ServerError(APIError):
|
|
769
|
+
"""Server error (5xx)."""
|
|
770
|
+
pass
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class TimeoutError(APIError):
|
|
774
|
+
"""Request timed out."""
|
|
775
|
+
pass
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class ConnectionError(APIError):
|
|
779
|
+
"""Connection failed."""
|
|
780
|
+
pass
|
|
781
|
+
</content>
|
|
782
|
+
</create>
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
error handling middleware
|
|
786
|
+
|
|
787
|
+
<read><file>src/api/base_client.py</file></read>
|
|
788
|
+
|
|
789
|
+
<edit>
|
|
790
|
+
<file>src/api/base_client.py</file>
|
|
791
|
+
<find>
|
|
792
|
+
"""Base API client with common functionality."""
|
|
793
|
+
from typing import Any, Dict, Optional
|
|
794
|
+
import requests
|
|
795
|
+
from requests.adapters import HTTPAdapter
|
|
796
|
+
from requests.models import Response
|
|
797
|
+
from urllib3.util.retry import Retry
|
|
798
|
+
</find>
|
|
799
|
+
<replace>
|
|
800
|
+
"""Base API client with common functionality."""
|
|
801
|
+
from typing import Any, Dict, Optional
|
|
802
|
+
import requests
|
|
803
|
+
from requests.adapters import HTTPAdapter
|
|
804
|
+
from requests.models import Response
|
|
805
|
+
from urllib3.util.retry import Retry
|
|
806
|
+
|
|
807
|
+
from .exceptions import (
|
|
808
|
+
APIError,
|
|
809
|
+
AuthenticationError,
|
|
810
|
+
AuthorizationError,
|
|
811
|
+
RateLimitError,
|
|
812
|
+
ValidationError,
|
|
813
|
+
NotFoundError,
|
|
814
|
+
ConflictError,
|
|
815
|
+
ServerError
|
|
816
|
+
)
|
|
817
|
+
</replace>
|
|
818
|
+
</edit>
|
|
819
|
+
|
|
820
|
+
<edit>
|
|
821
|
+
<file>src/api/base_client.py</file>
|
|
822
|
+
<find>
|
|
823
|
+
def delete(self, path: str) -> Response:
|
|
824
|
+
"""Make DELETE request."""
|
|
825
|
+
url = self._build_url(path)
|
|
826
|
+
response = self.session.delete(
|
|
827
|
+
url,
|
|
828
|
+
headers=self._get_headers(),
|
|
829
|
+
timeout=self.timeout
|
|
830
|
+
)
|
|
831
|
+
response.raise_for_status()
|
|
832
|
+
return response
|
|
833
|
+
</find>
|
|
834
|
+
<replace>
|
|
835
|
+
def delete(self, path: str) -> Response:
|
|
836
|
+
"""Make DELETE request."""
|
|
837
|
+
url = self._build_url(path)
|
|
838
|
+
response = self.session.delete(
|
|
839
|
+
url,
|
|
840
|
+
headers=self._get_headers(),
|
|
841
|
+
timeout=self.timeout
|
|
842
|
+
)
|
|
843
|
+
self._handle_errors(response)
|
|
844
|
+
return response
|
|
845
|
+
|
|
846
|
+
def _handle_errors(self, response: Response):
|
|
847
|
+
"""Handle API response errors with specific exceptions."""
|
|
848
|
+
if response.ok:
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
status_code = response.status_code
|
|
852
|
+
|
|
853
|
+
try:
|
|
854
|
+
error_data = response.json()
|
|
855
|
+
message = error_data.get("message", error_data.get("error", "Unknown error"))
|
|
856
|
+
except ValueError:
|
|
857
|
+
error_data = {}
|
|
858
|
+
message = response.text or "Unknown error"
|
|
859
|
+
|
|
860
|
+
if status_code == 401:
|
|
861
|
+
raise AuthenticationError(message, status_code, error_data)
|
|
862
|
+
elif status_code == 403:
|
|
863
|
+
raise AuthorizationError(message, status_code, error_data)
|
|
864
|
+
elif status_code == 404:
|
|
865
|
+
raise NotFoundError(message, status_code, error_data)
|
|
866
|
+
elif status_code == 409:
|
|
867
|
+
raise ConflictError(message, status_code, error_data)
|
|
868
|
+
elif status_code == 429:
|
|
869
|
+
retry_after = response.headers.get("Retry-After")
|
|
870
|
+
retry_after = int(retry_after) if retry_after else None
|
|
871
|
+
raise RateLimitError(message, retry_after, error_data)
|
|
872
|
+
elif 400 <= status_code < 500:
|
|
873
|
+
raise ValidationError(message, status_code, error_data)
|
|
874
|
+
elif 500 <= status_code < 600:
|
|
875
|
+
raise ServerError(message, status_code, error_data)
|
|
876
|
+
else:
|
|
877
|
+
raise APIError(message, status_code, error_data)
|
|
878
|
+
</replace>
|
|
879
|
+
</edit>
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
PHASE 5: RATE LIMITING
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
understanding rate limits
|
|
886
|
+
|
|
887
|
+
common rate limit types:
|
|
888
|
+
- requests per minute/hour
|
|
889
|
+
- concurrent connections
|
|
890
|
+
- burst allowance
|
|
891
|
+
- tiered limits (free vs paid)
|
|
892
|
+
|
|
893
|
+
check headers:
|
|
894
|
+
- X-RateLimit-Limit
|
|
895
|
+
- X-RateLimit-Remaining
|
|
896
|
+
- X-RateLimit-Reset
|
|
897
|
+
- Retry-After
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
token bucket rate limiter
|
|
901
|
+
|
|
902
|
+
<create>
|
|
903
|
+
<file>src/api/rate_limiter.py</file>
|
|
904
|
+
<content>
|
|
905
|
+
"""Rate limiting for API requests."""
|
|
906
|
+
from typing import Optional
|
|
907
|
+
import time
|
|
908
|
+
from collections import deque
|
|
909
|
+
from threading import Lock
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
class TokenBucket:
|
|
913
|
+
"""Token bucket rate limiter."""
|
|
914
|
+
|
|
915
|
+
def __init__(self, rate: float, capacity: int):
|
|
916
|
+
"""
|
|
917
|
+
Args:
|
|
918
|
+
rate: tokens per second
|
|
919
|
+
capacity: bucket capacity
|
|
920
|
+
"""
|
|
921
|
+
self.rate = rate
|
|
922
|
+
self.capacity = capacity
|
|
923
|
+
self.tokens = float(capacity)
|
|
924
|
+
self.last_update = time.time()
|
|
925
|
+
self._lock = Lock()
|
|
926
|
+
|
|
927
|
+
def _refill(self):
|
|
928
|
+
"""Refill tokens based on elapsed time."""
|
|
929
|
+
now = time.time()
|
|
930
|
+
elapsed = now - self.last_update
|
|
931
|
+
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
|
|
932
|
+
self.last_update = now
|
|
933
|
+
|
|
934
|
+
def acquire(self, tokens: float = 1.0) -> bool:
|
|
935
|
+
"""Try to acquire tokens. Returns True if successful."""
|
|
936
|
+
with self._lock:
|
|
937
|
+
self._refill()
|
|
938
|
+
if self.tokens >= tokens:
|
|
939
|
+
self.tokens -= tokens
|
|
940
|
+
return True
|
|
941
|
+
return False
|
|
942
|
+
|
|
943
|
+
def wait_for_token(self, tokens: float = 1.0):
|
|
944
|
+
"""Wait until tokens are available."""
|
|
945
|
+
while not self.acquire(tokens):
|
|
946
|
+
# calculate wait time
|
|
947
|
+
self._refill()
|
|
948
|
+
deficit = tokens - self.tokens
|
|
949
|
+
wait_time = deficit / self.rate
|
|
950
|
+
if wait_time > 0:
|
|
951
|
+
time.sleep(wait_time)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
class SlidingWindowRateLimiter:
|
|
955
|
+
"""Sliding window rate limiter."""
|
|
956
|
+
|
|
957
|
+
def __init__(self, max_requests: int, window_seconds: int):
|
|
958
|
+
"""
|
|
959
|
+
Args:
|
|
960
|
+
max_requests: maximum requests in window
|
|
961
|
+
window_seconds: time window in seconds
|
|
962
|
+
"""
|
|
963
|
+
self.max_requests = max_requests
|
|
964
|
+
self.window_seconds = window_seconds
|
|
965
|
+
self.requests = deque()
|
|
966
|
+
self._lock = Lock()
|
|
967
|
+
|
|
968
|
+
def _clean_old_requests(self):
|
|
969
|
+
"""Remove requests outside the time window."""
|
|
970
|
+
now = time.time()
|
|
971
|
+
cutoff = now - self.window_seconds
|
|
972
|
+
while self.requests and self.requests[0] < cutoff:
|
|
973
|
+
self.requests.popleft()
|
|
974
|
+
|
|
975
|
+
def acquire(self) -> bool:
|
|
976
|
+
"""Try to acquire a request slot."""
|
|
977
|
+
with self._lock:
|
|
978
|
+
self._clean_old_requests()
|
|
979
|
+
if len(self.requests) < self.max_requests:
|
|
980
|
+
self.requests.append(time.time())
|
|
981
|
+
return True
|
|
982
|
+
return False
|
|
983
|
+
|
|
984
|
+
def wait_for_slot(self):
|
|
985
|
+
"""Wait until a request slot is available."""
|
|
986
|
+
while not self.acquire():
|
|
987
|
+
self._clean_old_requests()
|
|
988
|
+
if self.requests:
|
|
989
|
+
# wait until oldest request expires
|
|
990
|
+
oldest = self.requests[0]
|
|
991
|
+
wait_time = self.window_seconds - (time.time() - oldest)
|
|
992
|
+
if wait_time > 0:
|
|
993
|
+
time.sleep(wait_time)
|
|
994
|
+
</content>
|
|
995
|
+
</create>
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
adaptive rate limiting
|
|
999
|
+
|
|
1000
|
+
<create>
|
|
1001
|
+
<file>src/api/adaptive_limiter.py</file>
|
|
1002
|
+
<content>
|
|
1003
|
+
"""Adaptive rate limiter that responds to server signals."""
|
|
1004
|
+
from typing import Optional
|
|
1005
|
+
import time
|
|
1006
|
+
from .rate_limiter import TokenBucket
|
|
1007
|
+
from .exceptions import RateLimitError
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
class AdaptiveRateLimiter:
|
|
1011
|
+
"""Rate limiter that adapts based on API responses."""
|
|
1012
|
+
|
|
1013
|
+
def __init__(
|
|
1014
|
+
self,
|
|
1015
|
+
initial_rate: float = 10.0,
|
|
1016
|
+
min_rate: float = 1.0,
|
|
1017
|
+
max_rate: float = 100.0
|
|
1018
|
+
):
|
|
1019
|
+
self.initial_rate = initial_rate
|
|
1020
|
+
self.min_rate = min_rate
|
|
1021
|
+
self.max_rate = max_rate
|
|
1022
|
+
self.current_rate = initial_rate
|
|
1023
|
+
self.bucket = TokenBucket(rate=initial_rate, capacity=10)
|
|
1024
|
+
self.last_error_time: Optional[float] = None
|
|
1025
|
+
self.consecutive_errors = 0
|
|
1026
|
+
|
|
1027
|
+
def acquire(self) -> bool:
|
|
1028
|
+
"""Acquire a token."""
|
|
1029
|
+
return self.bucket.acquire()
|
|
1030
|
+
|
|
1031
|
+
def wait_for_token(self):
|
|
1032
|
+
"""Wait until token available."""
|
|
1033
|
+
self.bucket.wait_for_token()
|
|
1034
|
+
|
|
1035
|
+
def report_success(self):
|
|
1036
|
+
"""Report successful request - can increase rate."""
|
|
1037
|
+
self.consecutive_errors = 0
|
|
1038
|
+
|
|
1039
|
+
# gradually increase rate back to initial
|
|
1040
|
+
if self.current_rate < self.initial_rate:
|
|
1041
|
+
self.current_rate = min(self.initial_rate, self.current_rate * 1.1)
|
|
1042
|
+
self.bucket = TokenBucket(rate=self.current_rate, capacity=10)
|
|
1043
|
+
|
|
1044
|
+
def report_rate_limit_error(self, error: RateLimitError):
|
|
1045
|
+
"""Report rate limit error - decrease rate."""
|
|
1046
|
+
self.consecutive_errors += 1
|
|
1047
|
+
self.last_error_time = time.time()
|
|
1048
|
+
|
|
1049
|
+
# reduce rate based on consecutive errors
|
|
1050
|
+
reduction_factor = 0.5 ** self.consecutive_errors
|
|
1051
|
+
self.current_rate = max(
|
|
1052
|
+
self.min_rate,
|
|
1053
|
+
self.current_rate * reduction_factor
|
|
1054
|
+
)
|
|
1055
|
+
self.bucket = TokenBucket(rate=self.current_rate, capacity=10)
|
|
1056
|
+
|
|
1057
|
+
# respect retry-after if provided
|
|
1058
|
+
if error.retry_after:
|
|
1059
|
+
wait_time = error.retry_after
|
|
1060
|
+
else:
|
|
1061
|
+
wait_time = 2.0 ** self.consecutive_errors # exponential backoff
|
|
1062
|
+
|
|
1063
|
+
time.sleep(wait_time)
|
|
1064
|
+
</content>
|
|
1065
|
+
</create>
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
PHASE 6: RETRY STRATEGIES
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
exponential backoff
|
|
1072
|
+
|
|
1073
|
+
<create>
|
|
1074
|
+
<file>src/api/retry.py</file>
|
|
1075
|
+
<content>
|
|
1076
|
+
"""Retry strategies for API calls."""
|
|
1077
|
+
from typing import Optional, Callable, Type, Tuple
|
|
1078
|
+
import time
|
|
1079
|
+
import random
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def calculate_backoff(
|
|
1083
|
+
attempt: int,
|
|
1084
|
+
base_delay: float = 1.0,
|
|
1085
|
+
max_delay: float = 60.0,
|
|
1086
|
+
exponential_base: float = 2.0,
|
|
1087
|
+
jitter: bool = True
|
|
1088
|
+
) -> float:
|
|
1089
|
+
"""Calculate exponential backoff delay."""
|
|
1090
|
+
delay = min(base_delay * (exponential_base ** attempt), max_delay)
|
|
1091
|
+
|
|
1092
|
+
if jitter:
|
|
1093
|
+
# add randomness to prevent thundering herd
|
|
1094
|
+
delay = delay * (0.5 + random.random() * 0.5)
|
|
1095
|
+
|
|
1096
|
+
return delay
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
class RetryConfig:
|
|
1100
|
+
"""Configuration for retry behavior."""
|
|
1101
|
+
|
|
1102
|
+
def __init__(
|
|
1103
|
+
self,
|
|
1104
|
+
max_attempts: int = 3,
|
|
1105
|
+
base_delay: float = 1.0,
|
|
1106
|
+
max_delay: float = 60.0,
|
|
1107
|
+
retryable_status_codes: Tuple[int, ...] = (429, 500, 502, 503, 504),
|
|
1108
|
+
retryable_exceptions: Tuple[Type[Exception], ...] = (
|
|
1109
|
+
ConnectionError,
|
|
1110
|
+
TimeoutError
|
|
1111
|
+
)
|
|
1112
|
+
):
|
|
1113
|
+
self.max_attempts = max_attempts
|
|
1114
|
+
self.base_delay = base_delay
|
|
1115
|
+
self.max_delay = max_delay
|
|
1116
|
+
self.retryable_status_codes = retryable_status_codes
|
|
1117
|
+
self.retryable_exceptions = retryable_exceptions
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def retry_with_backoff(
|
|
1121
|
+
func: Callable,
|
|
1122
|
+
config: Optional[RetryConfig] = None,
|
|
1123
|
+
on_retry: Optional[Callable[[int, Exception], None]] = None
|
|
1124
|
+
):
|
|
1125
|
+
"""Decorator for retrying function calls with exponential backoff."""
|
|
1126
|
+
|
|
1127
|
+
if config is None:
|
|
1128
|
+
config = RetryConfig()
|
|
1129
|
+
|
|
1130
|
+
def wrapper(*args, **kwargs):
|
|
1131
|
+
last_exception = None
|
|
1132
|
+
|
|
1133
|
+
for attempt in range(config.max_attempts):
|
|
1134
|
+
try:
|
|
1135
|
+
return func(*args, **kwargs)
|
|
1136
|
+
except Exception as e:
|
|
1137
|
+
last_exception = e
|
|
1138
|
+
|
|
1139
|
+
# check if exception is retryable
|
|
1140
|
+
if not isinstance(e, config.retryable_exceptions):
|
|
1141
|
+
raise
|
|
1142
|
+
|
|
1143
|
+
# check if should retry
|
|
1144
|
+
if attempt < config.max_attempts - 1:
|
|
1145
|
+
delay = calculate_backoff(attempt, config.base_delay, config.max_delay)
|
|
1146
|
+
|
|
1147
|
+
if on_retry:
|
|
1148
|
+
on_retry(attempt + 1, e)
|
|
1149
|
+
|
|
1150
|
+
time.sleep(delay)
|
|
1151
|
+
else:
|
|
1152
|
+
raise
|
|
1153
|
+
|
|
1154
|
+
raise last_exception
|
|
1155
|
+
|
|
1156
|
+
return wrapper
|
|
1157
|
+
</content>
|
|
1158
|
+
</create>
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
usage example
|
|
1162
|
+
|
|
1163
|
+
from src.api.retry import retry_with_backoff, RetryConfig
|
|
1164
|
+
from src.api.exceptions import ServerError
|
|
1165
|
+
|
|
1166
|
+
config = RetryConfig(
|
|
1167
|
+
max_attempts=5,
|
|
1168
|
+
base_delay=0.5,
|
|
1169
|
+
max_delay=30.0,
|
|
1170
|
+
retryable_status_codes=(429, 500, 502, 503, 504)
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
@retry_with_backoff(config=config)
|
|
1174
|
+
def fetch_user_data(user_id: int):
|
|
1175
|
+
return client.get(f"/users/{user_id}")
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
PHASE 7: PAGINATION
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
cursor-based pagination
|
|
1182
|
+
|
|
1183
|
+
<create>
|
|
1184
|
+
<file>src/api/pagination.py</file>
|
|
1185
|
+
<content>
|
|
1186
|
+
"""Pagination handling for API responses."""
|
|
1187
|
+
from typing import Iterator, List, Optional, TypeVar, Generic
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
T = TypeVar("T")
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
class CursorPage(Generic[T]):
|
|
1194
|
+
"""Single page of cursor-paginated results."""
|
|
1195
|
+
|
|
1196
|
+
def __init__(
|
|
1197
|
+
self,
|
|
1198
|
+
items: List[T],
|
|
1199
|
+
next_cursor: Optional[str] = None,
|
|
1200
|
+
has_more: bool = False
|
|
1201
|
+
):
|
|
1202
|
+
self.items = items
|
|
1203
|
+
self.next_cursor = next_cursor
|
|
1204
|
+
self.has_more = has_more
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
class CursorPaginator(Generic[T]):
|
|
1208
|
+
"""Iterator for cursor-based pagination."""
|
|
1209
|
+
|
|
1210
|
+
def __init__(self, fetch_function: callable, page_size: int = 100):
|
|
1211
|
+
"""
|
|
1212
|
+
Args:
|
|
1213
|
+
fetch_function: callable that takes (cursor, limit) and returns CursorPage
|
|
1214
|
+
page_size: number of items per page
|
|
1215
|
+
"""
|
|
1216
|
+
self.fetch_function = fetch_function
|
|
1217
|
+
self.page_size = page_size
|
|
1218
|
+
|
|
1219
|
+
def __iter__(self) -> Iterator[T]:
|
|
1220
|
+
"""Iterate through all pages."""
|
|
1221
|
+
cursor = None
|
|
1222
|
+
while True:
|
|
1223
|
+
page = self.fetch_function(cursor=cursor, limit=self.page_size)
|
|
1224
|
+
yield from page.items
|
|
1225
|
+
|
|
1226
|
+
if not page.has_more or not page.next_cursor:
|
|
1227
|
+
break
|
|
1228
|
+
cursor = page.next_cursor
|
|
1229
|
+
|
|
1230
|
+
def get_all(self) -> List[T]:
|
|
1231
|
+
"""Fetch all items as a list."""
|
|
1232
|
+
return list(self.__iter__())
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
class OffsetPage(Generic[T]):
|
|
1236
|
+
"""Single page of offset-based results."""
|
|
1237
|
+
|
|
1238
|
+
def __init__(
|
|
1239
|
+
self,
|
|
1240
|
+
items: List[T],
|
|
1241
|
+
total: int,
|
|
1242
|
+
offset: int,
|
|
1243
|
+
limit: int
|
|
1244
|
+
):
|
|
1245
|
+
self.items = items
|
|
1246
|
+
self.total = total
|
|
1247
|
+
self.offset = offset
|
|
1248
|
+
self.limit = limit
|
|
1249
|
+
|
|
1250
|
+
@property
|
|
1251
|
+
def has_more(self) -> bool:
|
|
1252
|
+
"""Check if more pages available."""
|
|
1253
|
+
return self.offset + self.limit < self.total
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
class OffsetPaginator(Generic[T]):
|
|
1257
|
+
"""Iterator for offset-based pagination."""
|
|
1258
|
+
|
|
1259
|
+
def __init__(
|
|
1260
|
+
self,
|
|
1261
|
+
fetch_function: callable,
|
|
1262
|
+
page_size: int = 100,
|
|
1263
|
+
starting_offset: int = 0
|
|
1264
|
+
):
|
|
1265
|
+
"""
|
|
1266
|
+
Args:
|
|
1267
|
+
fetch_function: callable that takes (offset, limit) and returns OffsetPage
|
|
1268
|
+
page_size: number of items per page
|
|
1269
|
+
starting_offset: initial offset
|
|
1270
|
+
"""
|
|
1271
|
+
self.fetch_function = fetch_function
|
|
1272
|
+
self.page_size = page_size
|
|
1273
|
+
self.starting_offset = starting_offset
|
|
1274
|
+
|
|
1275
|
+
def __iter__(self) -> Iterator[T]:
|
|
1276
|
+
"""Iterate through all pages."""
|
|
1277
|
+
offset = self.starting_offset
|
|
1278
|
+
|
|
1279
|
+
while True:
|
|
1280
|
+
page = self.fetch_function(offset=offset, limit=self.page_size)
|
|
1281
|
+
yield from page.items
|
|
1282
|
+
|
|
1283
|
+
if not page.has_more:
|
|
1284
|
+
break
|
|
1285
|
+
offset += self.page_size
|
|
1286
|
+
|
|
1287
|
+
def get_all(self) -> List[T]:
|
|
1288
|
+
"""Fetch all items as a list."""
|
|
1289
|
+
return list(self.__iter__())
|
|
1290
|
+
|
|
1291
|
+
def page_at(self, page_number: int) -> List[T]:
|
|
1292
|
+
"""Get items at specific page number (1-indexed)."""
|
|
1293
|
+
offset = (page_number - 1) * self.page_size
|
|
1294
|
+
page = self.fetch_function(offset=offset, limit=self.page_size)
|
|
1295
|
+
return page.items
|
|
1296
|
+
</content>
|
|
1297
|
+
</create>
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
PHASE 8: RESPONSE VALIDATION
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
pydantic models for validation
|
|
1304
|
+
|
|
1305
|
+
<create>
|
|
1306
|
+
<file>src/api/models.py</file>
|
|
1307
|
+
<content>
|
|
1308
|
+
"""Pydantic models for API request/response validation."""
|
|
1309
|
+
from typing import List, Optional, Generic, TypeVar
|
|
1310
|
+
from datetime import datetime
|
|
1311
|
+
from pydantic import BaseModel, Field, validator
|
|
1312
|
+
from enum import Enum
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
class UserRole(str, Enum):
|
|
1316
|
+
"""User role enumeration."""
|
|
1317
|
+
ADMIN = "admin"
|
|
1318
|
+
USER = "user"
|
|
1319
|
+
GUEST = "guest"
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
class User(BaseModel):
|
|
1323
|
+
"""User model."""
|
|
1324
|
+
id: int = Field(..., description="Unique user identifier")
|
|
1325
|
+
name: str = Field(..., min_length=1, max_length=100)
|
|
1326
|
+
email: str = Field(..., regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|
1327
|
+
role: UserRole = UserRole.USER
|
|
1328
|
+
created_at: datetime
|
|
1329
|
+
updated_at: Optional[datetime] = None
|
|
1330
|
+
|
|
1331
|
+
@validator("email")
|
|
1332
|
+
def email_must_be_lowercase(cls, v):
|
|
1333
|
+
"""Ensure email is lowercase."""
|
|
1334
|
+
return v.lower()
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
class CreateUserRequest(BaseModel):
|
|
1338
|
+
"""Request model for creating user."""
|
|
1339
|
+
name: str = Field(..., min_length=1, max_length=100)
|
|
1340
|
+
email: str = Field(..., regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|
1341
|
+
role: UserRole = UserRole.USER
|
|
1342
|
+
password: str = Field(..., min_length=8, max_length=100)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
class UpdateUserRequest(BaseModel):
|
|
1346
|
+
"""Request model for updating user."""
|
|
1347
|
+
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
1348
|
+
email: Optional[str] = Field(None, regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|
1349
|
+
role: Optional[UserRole] = None
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
class UserListResponse(BaseModel):
|
|
1353
|
+
"""Response model for user list."""
|
|
1354
|
+
items: List[User]
|
|
1355
|
+
total: int
|
|
1356
|
+
page: int
|
|
1357
|
+
page_size: int
|
|
1358
|
+
has_more: bool
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
class ErrorResponse(BaseModel):
|
|
1362
|
+
"""Error response model."""
|
|
1363
|
+
error: str
|
|
1364
|
+
message: str
|
|
1365
|
+
details: Optional[dict] = None
|
|
1366
|
+
</content>
|
|
1367
|
+
</create>
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
response parser
|
|
1371
|
+
|
|
1372
|
+
<create>
|
|
1373
|
+
<file>src/api/response_parser.py</file>
|
|
1374
|
+
<content>
|
|
1375
|
+
"""Response parsing and validation."""
|
|
1376
|
+
from typing import TypeVar, Type, Optional
|
|
1377
|
+
from pydantic import BaseModel, ValidationError
|
|
1378
|
+
from .exceptions import APIError
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
T = TypeVar("T", bound=BaseModel)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
class ResponseParser:
|
|
1385
|
+
"""Parse and validate API responses."""
|
|
1386
|
+
|
|
1387
|
+
@staticmethod
|
|
1388
|
+
def parse(response_data: dict, model: Type[T]) -> T:
|
|
1389
|
+
"""Parse response data into pydantic model."""
|
|
1390
|
+
try:
|
|
1391
|
+
return model(**response_data)
|
|
1392
|
+
except ValidationError as e:
|
|
1393
|
+
raise APIError(
|
|
1394
|
+
f"Response validation failed: {e}",
|
|
1395
|
+
response_data={"validation_errors": e.errors()}
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
@staticmethod
|
|
1399
|
+
def parse_optional(response_data: Optional[dict], model: Type[T]) -> Optional[T]:
|
|
1400
|
+
"""Parse optional response data."""
|
|
1401
|
+
if response_data is None:
|
|
1402
|
+
return None
|
|
1403
|
+
return ResponseParser.parse(response_data, model)
|
|
1404
|
+
|
|
1405
|
+
@staticmethod
|
|
1406
|
+
def parse_list(response_data: dict, items_key: str, model: Type[T]) -> list:
|
|
1407
|
+
"""Parse response containing a list of items."""
|
|
1408
|
+
if items_key not in response_data:
|
|
1409
|
+
raise APIError(f"Response missing key: {items_key}")
|
|
1410
|
+
|
|
1411
|
+
items = response_data[items_key]
|
|
1412
|
+
if not isinstance(items, list):
|
|
1413
|
+
raise APIError(f"Expected list for key {items_key}, got {type(items)}")
|
|
1414
|
+
|
|
1415
|
+
result = []
|
|
1416
|
+
for item in items:
|
|
1417
|
+
try:
|
|
1418
|
+
result.append(model(**item))
|
|
1419
|
+
except ValidationError as e:
|
|
1420
|
+
raise APIError(f"Item validation failed: {e}")
|
|
1421
|
+
return result
|
|
1422
|
+
</content>
|
|
1423
|
+
</create>
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
PHASE 9: CACHING STRATEGIES
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
simple in-memory cache
|
|
1430
|
+
|
|
1431
|
+
<create>
|
|
1432
|
+
<file>src/api/cache.py</file>
|
|
1433
|
+
<content>
|
|
1434
|
+
"""Caching for API responses."""
|
|
1435
|
+
from typing import Optional, Dict, Any, Callable
|
|
1436
|
+
from datetime import datetime, timedelta
|
|
1437
|
+
from functools import wraps
|
|
1438
|
+
from hashlib import sha256
|
|
1439
|
+
import json
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
class CacheEntry:
|
|
1443
|
+
"""Single cache entry."""
|
|
1444
|
+
|
|
1445
|
+
def __init__(self, value: Any, ttl_seconds: int):
|
|
1446
|
+
self.value = value
|
|
1447
|
+
self.expires_at = datetime.now() + timedelta(seconds=ttl_seconds)
|
|
1448
|
+
|
|
1449
|
+
@property
|
|
1450
|
+
def is_expired(self) -> bool:
|
|
1451
|
+
"""Check if entry has expired."""
|
|
1452
|
+
return datetime.now() >= self.expires_at
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
class MemoryCache:
|
|
1456
|
+
"""Simple in-memory cache with TTL."""
|
|
1457
|
+
|
|
1458
|
+
def __init__(self):
|
|
1459
|
+
self._storage: Dict[str, CacheEntry] = {}
|
|
1460
|
+
|
|
1461
|
+
def get(self, key: str) -> Optional[Any]:
|
|
1462
|
+
"""Get value from cache."""
|
|
1463
|
+
entry = self._storage.get(key)
|
|
1464
|
+
if entry is None:
|
|
1465
|
+
return None
|
|
1466
|
+
if entry.is_expired:
|
|
1467
|
+
del self._storage[key]
|
|
1468
|
+
return None
|
|
1469
|
+
return entry.value
|
|
1470
|
+
|
|
1471
|
+
def set(self, key: str, value: Any, ttl_seconds: int = 300):
|
|
1472
|
+
"""Set value in cache."""
|
|
1473
|
+
self._storage[key] = CacheEntry(value, ttl_seconds)
|
|
1474
|
+
|
|
1475
|
+
def invalidate(self, key: str):
|
|
1476
|
+
"""Invalidate cache entry."""
|
|
1477
|
+
self._storage.pop(key, None)
|
|
1478
|
+
|
|
1479
|
+
def clear(self):
|
|
1480
|
+
"""Clear all cache entries."""
|
|
1481
|
+
self._storage.clear()
|
|
1482
|
+
|
|
1483
|
+
def cleanup_expired(self):
|
|
1484
|
+
"""Remove all expired entries."""
|
|
1485
|
+
expired_keys = [
|
|
1486
|
+
k for k, v in self._storage.items()
|
|
1487
|
+
if v.is_expired
|
|
1488
|
+
]
|
|
1489
|
+
for key in expired_keys:
|
|
1490
|
+
del self._storage[key]
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def cache_response(
|
|
1494
|
+
cache: MemoryCache,
|
|
1495
|
+
ttl_seconds: int = 300,
|
|
1496
|
+
key_prefix: str = ""
|
|
1497
|
+
):
|
|
1498
|
+
"""Decorator for caching API responses."""
|
|
1499
|
+
|
|
1500
|
+
def decorator(func: Callable) -> Callable:
|
|
1501
|
+
@wraps(func)
|
|
1502
|
+
def wrapper(*args, **kwargs):
|
|
1503
|
+
# generate cache key
|
|
1504
|
+
key_parts = [key_prefix]
|
|
1505
|
+
key_parts.extend(str(a) for a in args)
|
|
1506
|
+
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
|
1507
|
+
cache_key = sha256("|".join(key_parts).encode()).hexdigest()
|
|
1508
|
+
|
|
1509
|
+
# try cache first
|
|
1510
|
+
cached = cache.get(cache_key)
|
|
1511
|
+
if cached is not None:
|
|
1512
|
+
return cached
|
|
1513
|
+
|
|
1514
|
+
# call function and cache result
|
|
1515
|
+
result = func(*args, **kwargs)
|
|
1516
|
+
cache.set(cache_key, result, ttl_seconds)
|
|
1517
|
+
return result
|
|
1518
|
+
|
|
1519
|
+
return wrapper
|
|
1520
|
+
|
|
1521
|
+
return decorator
|
|
1522
|
+
</content>
|
|
1523
|
+
</create>
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
PHASE 10: GRAPHQL INTEGRATION
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
graphql client
|
|
1530
|
+
|
|
1531
|
+
<create>
|
|
1532
|
+
<file>src/api/graphql_client.py</file>
|
|
1533
|
+
<content>
|
|
1534
|
+
"""GraphQL API client."""
|
|
1535
|
+
from typing import Any, Dict, Optional, List
|
|
1536
|
+
import requests
|
|
1537
|
+
from .base_client import BaseAPIClient
|
|
1538
|
+
from .exceptions import APIError
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
class GraphQLClient(BaseAPIClient):
|
|
1542
|
+
"""Client for GraphQL APIs."""
|
|
1543
|
+
|
|
1544
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None):
|
|
1545
|
+
super().__init__(base_url, api_key)
|
|
1546
|
+
# GraphQL typically doesn't use Accept: application/json
|
|
1547
|
+
# but some implementations do
|
|
1548
|
+
|
|
1549
|
+
def execute(
|
|
1550
|
+
self,
|
|
1551
|
+
query: str,
|
|
1552
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
1553
|
+
operation_name: Optional[str] = None
|
|
1554
|
+
) -> Dict[str, Any]:
|
|
1555
|
+
"""Execute GraphQL query."""
|
|
1556
|
+
payload = {"query": query}
|
|
1557
|
+
|
|
1558
|
+
if variables:
|
|
1559
|
+
payload["variables"] = variables
|
|
1560
|
+
if operation_name:
|
|
1561
|
+
payload["operationName"] = operation_name
|
|
1562
|
+
|
|
1563
|
+
response = self.session.post(
|
|
1564
|
+
self._build_url(""),
|
|
1565
|
+
json=payload,
|
|
1566
|
+
headers=self._get_headers()
|
|
1567
|
+
)
|
|
1568
|
+
self._handle_errors(response)
|
|
1569
|
+
|
|
1570
|
+
data = response.json()
|
|
1571
|
+
|
|
1572
|
+
# check for GraphQL errors
|
|
1573
|
+
if "errors" in data:
|
|
1574
|
+
errors = data["errors"]
|
|
1575
|
+
messages = [e.get("message", str(e)) for e in errors]
|
|
1576
|
+
raise APIError(f"GraphQL errors: {', '.join(messages)}")
|
|
1577
|
+
|
|
1578
|
+
return data.get("data", {})
|
|
1579
|
+
|
|
1580
|
+
def query(
|
|
1581
|
+
self,
|
|
1582
|
+
query: str,
|
|
1583
|
+
variables: Optional[Dict[str, Any]] = None
|
|
1584
|
+
) -> Dict[str, Any]:
|
|
1585
|
+
"""Execute a GraphQL query."""
|
|
1586
|
+
return self.execute(query, variables)
|
|
1587
|
+
|
|
1588
|
+
def mutate(
|
|
1589
|
+
self,
|
|
1590
|
+
mutation: str,
|
|
1591
|
+
variables: Optional[Dict[str, Any]] = None
|
|
1592
|
+
) -> Dict[str, Any]:
|
|
1593
|
+
"""Execute a GraphQL mutation."""
|
|
1594
|
+
return self.execute(mutation, variables)
|
|
1595
|
+
</content>
|
|
1596
|
+
</create>
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
graphql query builder
|
|
1600
|
+
|
|
1601
|
+
<create>
|
|
1602
|
+
<file>src/api/graphql_builder.py</file>
|
|
1603
|
+
<content>
|
|
1604
|
+
"""GraphQL query builder for type-safe queries."""
|
|
1605
|
+
from typing import List, Optional, Dict, Any
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
class GraphQLQueryBuilder:
|
|
1609
|
+
"""Builder for constructing GraphQL queries."""
|
|
1610
|
+
|
|
1611
|
+
def __init__(self, operation_type: str = "query"):
|
|
1612
|
+
self.operation_type = operation_type
|
|
1613
|
+
self.name: Optional[str] = None
|
|
1614
|
+
self.fields: List[str] = []
|
|
1615
|
+
self.arguments: Dict[str, str] = {}
|
|
1616
|
+
self.fragments: List[str] = []
|
|
1617
|
+
|
|
1618
|
+
def name_op(self, name: str) -> "GraphQLQueryBuilder":
|
|
1619
|
+
"""Set operation name."""
|
|
1620
|
+
self.name = name
|
|
1621
|
+
return self
|
|
1622
|
+
|
|
1623
|
+
def field(self, field_path: str) -> "GraphQLQueryBuilder":
|
|
1624
|
+
"""Add a field to query."""
|
|
1625
|
+
self.fields.append(field_path)
|
|
1626
|
+
return self
|
|
1627
|
+
|
|
1628
|
+
def fields(self, *field_paths: str) -> "GraphQLQueryBuilder":
|
|
1629
|
+
"""Add multiple fields."""
|
|
1630
|
+
self.fields.extend(field_paths)
|
|
1631
|
+
return self
|
|
1632
|
+
|
|
1633
|
+
def arg(self, key: str, value: Any) -> "GraphQLQueryBuilder":
|
|
1634
|
+
"""Add argument to operation."""
|
|
1635
|
+
if isinstance(value, str):
|
|
1636
|
+
self.arguments[key] = f'"{value}"'
|
|
1637
|
+
elif isinstance(value, bool):
|
|
1638
|
+
self.arguments[key] = str(value).lower()
|
|
1639
|
+
elif value is None:
|
|
1640
|
+
self.arguments[key] = "null"
|
|
1641
|
+
else:
|
|
1642
|
+
self.arguments[key] = str(value)
|
|
1643
|
+
return self
|
|
1644
|
+
|
|
1645
|
+
def args(self, **kwargs: Any) -> "GraphQLQueryBuilder":
|
|
1646
|
+
"""Add multiple arguments."""
|
|
1647
|
+
for key, value in kwargs.items():
|
|
1648
|
+
self.arg(key, value)
|
|
1649
|
+
return self
|
|
1650
|
+
|
|
1651
|
+
def fragment(self, fragment: str) -> "GraphQLQueryBuilder":
|
|
1652
|
+
"""Add a fragment."""
|
|
1653
|
+
self.fragments.append(fragment)
|
|
1654
|
+
return self
|
|
1655
|
+
|
|
1656
|
+
def build(self) -> str:
|
|
1657
|
+
"""Build the complete GraphQL query."""
|
|
1658
|
+
# operation declaration
|
|
1659
|
+
if self.name:
|
|
1660
|
+
args_str = ", ".join(f"${k}: {self._infer_type(v)}" for k, v in self.arguments.items())
|
|
1661
|
+
operation = f"{self.operation_type} {self.name}"
|
|
1662
|
+
if args_str:
|
|
1663
|
+
operation += f"({args_str})"
|
|
1664
|
+
else:
|
|
1665
|
+
operation = self.operation_type
|
|
1666
|
+
|
|
1667
|
+
# field arguments
|
|
1668
|
+
field_args = ""
|
|
1669
|
+
if self.arguments:
|
|
1670
|
+
field_args = "(" + ", ".join(f"{k}: ${k}" for k in self.arguments.keys()) + ")"
|
|
1671
|
+
|
|
1672
|
+
# selection set
|
|
1673
|
+
selection = "\n ".join(self.fields)
|
|
1674
|
+
|
|
1675
|
+
# combine
|
|
1676
|
+
query = f"{operation} {{{field_args}\n {selection}\n}}"
|
|
1677
|
+
|
|
1678
|
+
# add fragments
|
|
1679
|
+
if self.fragments:
|
|
1680
|
+
query += "\n\n" + "\n".join(self.fragments)
|
|
1681
|
+
|
|
1682
|
+
return query
|
|
1683
|
+
|
|
1684
|
+
def _infer_type(self, value: str) -> str:
|
|
1685
|
+
"""Infer GraphQL type from formatted value."""
|
|
1686
|
+
if value.startswith('"'):
|
|
1687
|
+
return "String"
|
|
1688
|
+
if value == "true" or value == "false":
|
|
1689
|
+
return "Boolean"
|
|
1690
|
+
if value == "null":
|
|
1691
|
+
return "ID"
|
|
1692
|
+
if "." in value:
|
|
1693
|
+
return "Float"
|
|
1694
|
+
return "Int"
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def query(name: str) -> GraphQLQueryBuilder:
|
|
1698
|
+
"""Start building a GraphQL query."""
|
|
1699
|
+
return GraphQLQueryBuilder("query").name_op(name)
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def mutation(name: str) -> GraphQLQueryBuilder:
|
|
1703
|
+
"""Start building a GraphQL mutation."""
|
|
1704
|
+
return GraphQLQueryBuilder("mutation").name_op(name)
|
|
1705
|
+
</content>
|
|
1706
|
+
</create>
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
PHASE 11: API TESTING
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
testing with mock responses
|
|
1713
|
+
|
|
1714
|
+
<create>
|
|
1715
|
+
<file>tests/test_api_client.py</file>
|
|
1716
|
+
<content>
|
|
1717
|
+
"""Tests for API client."""
|
|
1718
|
+
import pytest
|
|
1719
|
+
from unittest.mock import Mock, patch
|
|
1720
|
+
from src.api.base_client import BaseAPIClient
|
|
1721
|
+
from src.api.exceptions import NotFoundError, RateLimitError
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
@pytest.fixture
|
|
1725
|
+
def mock_response():
|
|
1726
|
+
"""Create mock response."""
|
|
1727
|
+
mock = Mock()
|
|
1728
|
+
mock.ok = True
|
|
1729
|
+
mock.status_code = 200
|
|
1730
|
+
mock.json.return_value = {"id": 1, "name": "Test"}
|
|
1731
|
+
return mock
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
@pytest.fixture
|
|
1735
|
+
def client():
|
|
1736
|
+
"""Create test client."""
|
|
1737
|
+
return BaseAPIClient(
|
|
1738
|
+
base_url="https://api.test.com",
|
|
1739
|
+
api_key="test_key"
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def test_get_request_builds_correct_url(client, mock_response):
|
|
1744
|
+
"""Test that GET builds correct URL."""
|
|
1745
|
+
with patch.object(client.session, "get", return_value=mock_response) as mock_get:
|
|
1746
|
+
client.get("/users/123")
|
|
1747
|
+
|
|
1748
|
+
mock_get.assert_called_once()
|
|
1749
|
+
called_url = mock_get.call_args[0][0]
|
|
1750
|
+
assert called_url == "https://api.test.com/users/123"
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def test_get_request_includes_auth_headers(client, mock_response):
|
|
1754
|
+
"""Test that GET includes auth headers."""
|
|
1755
|
+
with patch.object(client.session, "get", return_value=mock_response) as mock_get:
|
|
1756
|
+
client.get("/users")
|
|
1757
|
+
|
|
1758
|
+
headers = mock_get.call_args[1]["headers"]
|
|
1759
|
+
assert "Authorization" in headers
|
|
1760
|
+
assert headers["Authorization"] == "Bearer test_key"
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def test_404_raises_not_found(client):
|
|
1764
|
+
"""Test that 404 raises NotFoundError."""
|
|
1765
|
+
mock_resp = Mock()
|
|
1766
|
+
mock_resp.ok = False
|
|
1767
|
+
mock_resp.status_code = 404
|
|
1768
|
+
mock_resp.json.return_value = {"message": "Not found"}
|
|
1769
|
+
|
|
1770
|
+
with patch.object(client.session, "get", return_value=mock_resp):
|
|
1771
|
+
with pytest.raises(NotFoundError):
|
|
1772
|
+
client.get("/users/999")
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
def test_429_raises_rate_limit_error(client):
|
|
1776
|
+
"""Test that 429 raises RateLimitError."""
|
|
1777
|
+
mock_resp = Mock()
|
|
1778
|
+
mock_resp.ok = False
|
|
1779
|
+
mock_resp.status_code = 429
|
|
1780
|
+
mock_resp.headers = {"Retry-After": "60"}
|
|
1781
|
+
mock_resp.json.return_value = {"message": "Rate limit exceeded"}
|
|
1782
|
+
|
|
1783
|
+
with patch.object(client.session, "get", return_value=mock_resp):
|
|
1784
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
1785
|
+
client.get("/users")
|
|
1786
|
+
|
|
1787
|
+
assert exc_info.value.retry_after == 60
|
|
1788
|
+
</content>
|
|
1789
|
+
</create>
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
PHASE 12: LOGGING AND MONITORING
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
api client logging
|
|
1796
|
+
|
|
1797
|
+
<create>
|
|
1798
|
+
<file>src/api/logging.py</file>
|
|
1799
|
+
<content>
|
|
1800
|
+
"""Logging configuration for API clients."""
|
|
1801
|
+
import logging
|
|
1802
|
+
import time
|
|
1803
|
+
from typing import Optional, Dict, Any
|
|
1804
|
+
from requests.models import Response, PreparedRequest
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
class APILogger:
|
|
1808
|
+
"""Structured logging for API calls."""
|
|
1809
|
+
|
|
1810
|
+
def __init__(self, name: str = "api"):
|
|
1811
|
+
self.logger = logging.getLogger(name)
|
|
1812
|
+
|
|
1813
|
+
def log_request(
|
|
1814
|
+
self,
|
|
1815
|
+
method: str,
|
|
1816
|
+
url: str,
|
|
1817
|
+
headers: Optional[Dict[str, str]] = None,
|
|
1818
|
+
body: Optional[Any] = None
|
|
1819
|
+
):
|
|
1820
|
+
"""Log outgoing request."""
|
|
1821
|
+
self.logger.debug(
|
|
1822
|
+
"API Request",
|
|
1823
|
+
extra={
|
|
1824
|
+
"event": "api_request",
|
|
1825
|
+
"method": method,
|
|
1826
|
+
"url": self._sanitize_url(url),
|
|
1827
|
+
"has_body": body is not None
|
|
1828
|
+
}
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
def log_response(
|
|
1832
|
+
self,
|
|
1833
|
+
response: Response,
|
|
1834
|
+
duration_ms: float
|
|
1835
|
+
):
|
|
1836
|
+
"""Log received response."""
|
|
1837
|
+
self.logger.info(
|
|
1838
|
+
"API Response",
|
|
1839
|
+
extra={
|
|
1840
|
+
"event": "api_response",
|
|
1841
|
+
"status_code": response.status_code,
|
|
1842
|
+
"duration_ms": round(duration_ms, 2),
|
|
1843
|
+
"url": self._sanitize_url(str(response.url))
|
|
1844
|
+
}
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
def log_error(
|
|
1848
|
+
self,
|
|
1849
|
+
error: Exception,
|
|
1850
|
+
duration_ms: Optional[float] = None
|
|
1851
|
+
):
|
|
1852
|
+
"""Log API error."""
|
|
1853
|
+
self.logger.error(
|
|
1854
|
+
"API Error",
|
|
1855
|
+
extra={
|
|
1856
|
+
"event": "api_error",
|
|
1857
|
+
"error_type": type(error).__name__,
|
|
1858
|
+
"error_message": str(error),
|
|
1859
|
+
"duration_ms": round(duration_ms, 2) if duration_ms else None
|
|
1860
|
+
},
|
|
1861
|
+
exc_info=error
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
def _sanitize_url(self, url: str) -> str:
|
|
1865
|
+
"""Remove sensitive parameters from URL."""
|
|
1866
|
+
# remove API keys, tokens, passwords from URL
|
|
1867
|
+
import re
|
|
1868
|
+
sanitized = re.sub(r'([?&](api_key|token|password)=)[^&]*', r'\1***', url)
|
|
1869
|
+
return sanitized
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
class LoggedRequestMixin:
|
|
1873
|
+
"""Mixin for adding logging to API clients."""
|
|
1874
|
+
|
|
1875
|
+
def __init__(self, *args, **kwargs):
|
|
1876
|
+
super().__init__(*args, **kwargs)
|
|
1877
|
+
self.logger = APILogger(f"api.{self.__class__.__name__}")
|
|
1878
|
+
|
|
1879
|
+
def _logged_request(self, method: str, *args, **kwargs):
|
|
1880
|
+
"""Make request with logging."""
|
|
1881
|
+
import time
|
|
1882
|
+
start = time.time()
|
|
1883
|
+
|
|
1884
|
+
try:
|
|
1885
|
+
# log request
|
|
1886
|
+
self.logger.log_request(method, *args, **kwargs)
|
|
1887
|
+
|
|
1888
|
+
# make request
|
|
1889
|
+
response = super()._logged_request(method, *args, **kwargs)
|
|
1890
|
+
|
|
1891
|
+
# log response
|
|
1892
|
+
duration_ms = (time.time() - start) * 1000
|
|
1893
|
+
self.logger.log_response(response, duration_ms)
|
|
1894
|
+
|
|
1895
|
+
return response
|
|
1896
|
+
|
|
1897
|
+
except Exception as e:
|
|
1898
|
+
duration_ms = (time.time() - start) * 1000
|
|
1899
|
+
self.logger.log_error(e, duration_ms)
|
|
1900
|
+
raise
|
|
1901
|
+
</content>
|
|
1902
|
+
</create>
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
PHASE 13: API DOCUMENTATION GENERATION
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
openapi spec generator
|
|
1909
|
+
|
|
1910
|
+
<create>
|
|
1911
|
+
<file>src/api/openapi.py</file>
|
|
1912
|
+
<content>
|
|
1913
|
+
"""Generate OpenAPI documentation for API clients."""
|
|
1914
|
+
from typing import Dict, Any, List, Optional
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
class OpenAPIGenerator:
|
|
1918
|
+
"""Generate OpenAPI specification from API client."""
|
|
1919
|
+
|
|
1920
|
+
def __init__(self, title: str, version: str = "1.0.0"):
|
|
1921
|
+
self.spec = {
|
|
1922
|
+
"openapi": "3.0.0",
|
|
1923
|
+
"info": {
|
|
1924
|
+
"title": title,
|
|
1925
|
+
"version": version
|
|
1926
|
+
},
|
|
1927
|
+
"servers": [],
|
|
1928
|
+
"paths": {},
|
|
1929
|
+
"components": {
|
|
1930
|
+
"schemas": {},
|
|
1931
|
+
"securitySchemes": {}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
def add_server(self, url: str, description: Optional[str] = None):
|
|
1936
|
+
"""Add server URL."""
|
|
1937
|
+
server = {"url": url}
|
|
1938
|
+
if description:
|
|
1939
|
+
server["description"] = description
|
|
1940
|
+
self.spec["servers"].append(server)
|
|
1941
|
+
return self
|
|
1942
|
+
|
|
1943
|
+
def add_path(
|
|
1944
|
+
self,
|
|
1945
|
+
path: str,
|
|
1946
|
+
method: str,
|
|
1947
|
+
summary: Optional[str] = None,
|
|
1948
|
+
description: Optional[str] = None,
|
|
1949
|
+
parameters: Optional[List[Dict]] = None,
|
|
1950
|
+
request_body: Optional[Dict] = None,
|
|
1951
|
+
responses: Optional[Dict[int, Dict]] = None,
|
|
1952
|
+
tags: Optional[List[str]] = None
|
|
1953
|
+
):
|
|
1954
|
+
"""Add path to specification."""
|
|
1955
|
+
if path not in self.spec["paths"]:
|
|
1956
|
+
self.spec["paths"][path] = {}
|
|
1957
|
+
|
|
1958
|
+
operation: Dict[str, Any] = {}
|
|
1959
|
+
if summary:
|
|
1960
|
+
operation["summary"] = summary
|
|
1961
|
+
if description:
|
|
1962
|
+
operation["description"] = description
|
|
1963
|
+
if parameters:
|
|
1964
|
+
operation["parameters"] = parameters
|
|
1965
|
+
if request_body:
|
|
1966
|
+
operation["requestBody"] = request_body
|
|
1967
|
+
if responses:
|
|
1968
|
+
operation["responses"] = responses
|
|
1969
|
+
if tags:
|
|
1970
|
+
operation["tags"] = tags
|
|
1971
|
+
|
|
1972
|
+
self.spec["paths"][path][method.lower()] = operation
|
|
1973
|
+
return self
|
|
1974
|
+
|
|
1975
|
+
def add_schema(self, name: str, schema: Dict[str, Any]):
|
|
1976
|
+
"""Add schema to components."""
|
|
1977
|
+
self.spec["components"]["schemas"][name] = schema
|
|
1978
|
+
return self
|
|
1979
|
+
|
|
1980
|
+
def add_security_scheme(
|
|
1981
|
+
self,
|
|
1982
|
+
name: str,
|
|
1983
|
+
scheme_type: str,
|
|
1984
|
+
scheme: Optional[str] = None,
|
|
1985
|
+
bearer_format: Optional[str] = None
|
|
1986
|
+
):
|
|
1987
|
+
"""Add security scheme."""
|
|
1988
|
+
security_scheme: Dict[str, Any] = {"type": scheme_type}
|
|
1989
|
+
if scheme:
|
|
1990
|
+
security_scheme["scheme"] = scheme
|
|
1991
|
+
if bearer_format:
|
|
1992
|
+
security_scheme["bearerFormat"] = bearer_format
|
|
1993
|
+
|
|
1994
|
+
self.spec["components"]["securitySchemes"][name] = security_scheme
|
|
1995
|
+
return self
|
|
1996
|
+
|
|
1997
|
+
def generate(self) -> Dict[str, Any]:
|
|
1998
|
+
"""Generate complete OpenAPI spec."""
|
|
1999
|
+
return self.spec
|
|
2000
|
+
</content>
|
|
2001
|
+
</create>
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
PHASE 14: WEBHOOK HANDLING
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
webhook signature verification
|
|
2008
|
+
|
|
2009
|
+
<create>
|
|
2010
|
+
<file>src/api/webhooks.py</file>
|
|
2011
|
+
<content>
|
|
2012
|
+
"""Webhook signature verification and handling."""
|
|
2013
|
+
from typing import Optional, Callable
|
|
2014
|
+
from hashlib import hmac, sha256, sha512
|
|
2015
|
+
import json
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
class WebhookVerifier:
|
|
2019
|
+
"""Verify webhook signatures."""
|
|
2020
|
+
|
|
2021
|
+
def __init__(self, secret: str, header_name: str = "X-Signature"):
|
|
2022
|
+
self.secret = secret
|
|
2023
|
+
self.header_name = header_name
|
|
2024
|
+
|
|
2025
|
+
def verify(self, payload: bytes, signature: str) -> bool:
|
|
2026
|
+
"""Verify webhook signature."""
|
|
2027
|
+
expected = self._compute_signature(payload)
|
|
2028
|
+
return hmac.compare_digest(expected, signature)
|
|
2029
|
+
|
|
2030
|
+
def _compute_signature(self, payload: bytes) -> str:
|
|
2031
|
+
"""Compute HMAC signature."""
|
|
2032
|
+
mac = hmac.new(
|
|
2033
|
+
self.secret.encode(),
|
|
2034
|
+
payload,
|
|
2035
|
+
sha256
|
|
2036
|
+
)
|
|
2037
|
+
return f"sha256={mac.hexdigest()}"
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
class WebhookHandler:
|
|
2041
|
+
"""Handle incoming webhooks."""
|
|
2042
|
+
|
|
2043
|
+
def __init__(self, verifier: WebhookVerifier):
|
|
2044
|
+
self.verifier = verifier
|
|
2045
|
+
self.handlers: Dict[str, Callable] = {}
|
|
2046
|
+
|
|
2047
|
+
def on(self, event_type: str) -> Callable:
|
|
2048
|
+
"""Decorator to register handler for event type."""
|
|
2049
|
+
def decorator(func: Callable):
|
|
2050
|
+
self.handlers[event_type] = func
|
|
2051
|
+
return func
|
|
2052
|
+
return decorator
|
|
2053
|
+
|
|
2054
|
+
def handle(self, payload: bytes, signature: str) -> Optional[Any]:
|
|
2055
|
+
"""Handle incoming webhook."""
|
|
2056
|
+
if not self.verifier.verify(payload, signature):
|
|
2057
|
+
raise ValueError("Invalid webhook signature")
|
|
2058
|
+
|
|
2059
|
+
data = json.loads(payload)
|
|
2060
|
+
event_type = data.get("type") or data.get("event")
|
|
2061
|
+
|
|
2062
|
+
if event_type in self.handlers:
|
|
2063
|
+
return self.handlers[event_type](data)
|
|
2064
|
+
|
|
2065
|
+
return None
|
|
2066
|
+
</content>
|
|
2067
|
+
</create>
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
PHASE 15: API INTEGRATION RULES
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
while this skill is active, these rules are MANDATORY:
|
|
2074
|
+
|
|
2075
|
+
[1] ALWAYS implement rate limiting
|
|
2076
|
+
never assume the API can handle unlimited requests
|
|
2077
|
+
implement client-side limits even if server has limits
|
|
2078
|
+
|
|
2079
|
+
[2] NEVER hardcode API credentials
|
|
2080
|
+
use environment variables or secure vaults
|
|
2081
|
+
add .env to .gitignore immediately
|
|
2082
|
+
|
|
2083
|
+
[3] ALWAYS validate responses
|
|
2084
|
+
use pydantic models for type safety
|
|
2085
|
+
never trust API documentation alone
|
|
2086
|
+
|
|
2087
|
+
[4] IMPLEMENT retry logic with exponential backoff
|
|
2088
|
+
transient failures are common
|
|
2089
|
+
use jitter to prevent thundering herd
|
|
2090
|
+
|
|
2091
|
+
[5] LOG all API calls
|
|
2092
|
+
log request, response, duration
|
|
2093
|
+
sanitize sensitive data in logs
|
|
2094
|
+
|
|
2095
|
+
[6] HANDLE errors specifically
|
|
2096
|
+
catch specific exceptions, not generic Exception
|
|
2097
|
+
map API errors to domain errors
|
|
2098
|
+
|
|
2099
|
+
[7] USE async clients for high-volume operations
|
|
2100
|
+
httpx > requests for concurrent requests
|
|
2101
|
+
respect connection limits
|
|
2102
|
+
|
|
2103
|
+
[8] CACHE when appropriate
|
|
2104
|
+
cache GET requests that rarely change
|
|
2105
|
+
respect cache-control headers
|
|
2106
|
+
|
|
2107
|
+
[9] TIMEOUT every request
|
|
2108
|
+
never wait forever
|
|
2109
|
+
set reasonable defaults (30s for sync, 60s for async)
|
|
2110
|
+
|
|
2111
|
+
[10] WRITE tests for API integration
|
|
2112
|
+
mock responses in unit tests
|
|
2113
|
+
consider VCR for recording real responses
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
FINAL REMINDERS
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
api integration is about reliability
|
|
2120
|
+
|
|
2121
|
+
the best API integration is one that doesnt break.
|
|
2122
|
+
handle edge cases. handle failures. handle rate limits.
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
documentation is your friend
|
|
2126
|
+
|
|
2127
|
+
read the docs. bookmark the reference.
|
|
2128
|
+
save the openapi spec if available.
|
|
2129
|
+
understand the errors before they happen.
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
observability is non-negotiable
|
|
2133
|
+
|
|
2134
|
+
log everything. measure everything.
|
|
2135
|
+
you cant fix what you cant see.
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
when the api fails
|
|
2139
|
+
|
|
2140
|
+
your application should degrade gracefully.
|
|
2141
|
+
show cached data. show a friendly error.
|
|
2142
|
+
never crash the whole app because one api failed.
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
start simple, add complexity gradually
|
|
2146
|
+
|
|
2147
|
+
basic client first. then auth. then retries. then caching.
|
|
2148
|
+
each layer builds on the previous.
|
|
2149
|
+
|
|
2150
|
+
now go integrate some apis.
|