amazon-ads-mcp 0.2.7__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.
- amazon_ads_mcp/__init__.py +11 -0
- amazon_ads_mcp/auth/__init__.py +33 -0
- amazon_ads_mcp/auth/base.py +211 -0
- amazon_ads_mcp/auth/hooks.py +172 -0
- amazon_ads_mcp/auth/manager.py +791 -0
- amazon_ads_mcp/auth/oauth_state_store.py +277 -0
- amazon_ads_mcp/auth/providers/__init__.py +14 -0
- amazon_ads_mcp/auth/providers/direct.py +393 -0
- amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
- amazon_ads_mcp/auth/providers/openbridge.py +512 -0
- amazon_ads_mcp/auth/registry.py +146 -0
- amazon_ads_mcp/auth/secure_token_store.py +297 -0
- amazon_ads_mcp/auth/token_store.py +723 -0
- amazon_ads_mcp/config/__init__.py +5 -0
- amazon_ads_mcp/config/sampling.py +111 -0
- amazon_ads_mcp/config/settings.py +366 -0
- amazon_ads_mcp/exceptions.py +314 -0
- amazon_ads_mcp/middleware/__init__.py +11 -0
- amazon_ads_mcp/middleware/authentication.py +1474 -0
- amazon_ads_mcp/middleware/caching.py +177 -0
- amazon_ads_mcp/middleware/oauth.py +175 -0
- amazon_ads_mcp/middleware/sampling.py +112 -0
- amazon_ads_mcp/models/__init__.py +320 -0
- amazon_ads_mcp/models/amc_models.py +837 -0
- amazon_ads_mcp/models/api_responses.py +847 -0
- amazon_ads_mcp/models/base_models.py +215 -0
- amazon_ads_mcp/models/builtin_responses.py +496 -0
- amazon_ads_mcp/models/dsp_models.py +556 -0
- amazon_ads_mcp/models/stores_brands.py +610 -0
- amazon_ads_mcp/server/__init__.py +6 -0
- amazon_ads_mcp/server/__main__.py +6 -0
- amazon_ads_mcp/server/builtin_prompts.py +269 -0
- amazon_ads_mcp/server/builtin_tools.py +962 -0
- amazon_ads_mcp/server/file_routes.py +547 -0
- amazon_ads_mcp/server/html_templates.py +149 -0
- amazon_ads_mcp/server/mcp_server.py +327 -0
- amazon_ads_mcp/server/openapi_utils.py +158 -0
- amazon_ads_mcp/server/sampling_handler.py +251 -0
- amazon_ads_mcp/server/server_builder.py +751 -0
- amazon_ads_mcp/server/sidecar_loader.py +178 -0
- amazon_ads_mcp/server/transform_executor.py +827 -0
- amazon_ads_mcp/tools/__init__.py +22 -0
- amazon_ads_mcp/tools/cache_management.py +105 -0
- amazon_ads_mcp/tools/download_tools.py +267 -0
- amazon_ads_mcp/tools/identity.py +236 -0
- amazon_ads_mcp/tools/oauth.py +598 -0
- amazon_ads_mcp/tools/profile.py +150 -0
- amazon_ads_mcp/tools/profile_listing.py +285 -0
- amazon_ads_mcp/tools/region.py +320 -0
- amazon_ads_mcp/tools/region_identity.py +175 -0
- amazon_ads_mcp/utils/__init__.py +6 -0
- amazon_ads_mcp/utils/async_compat.py +215 -0
- amazon_ads_mcp/utils/errors.py +452 -0
- amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
- amazon_ads_mcp/utils/export_download_handler.py +579 -0
- amazon_ads_mcp/utils/header_resolver.py +81 -0
- amazon_ads_mcp/utils/http/__init__.py +56 -0
- amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
- amazon_ads_mcp/utils/http/client_manager.py +329 -0
- amazon_ads_mcp/utils/http/request.py +207 -0
- amazon_ads_mcp/utils/http/resilience.py +512 -0
- amazon_ads_mcp/utils/http/resilient_client.py +195 -0
- amazon_ads_mcp/utils/http/retry.py +76 -0
- amazon_ads_mcp/utils/http_client.py +873 -0
- amazon_ads_mcp/utils/media/__init__.py +21 -0
- amazon_ads_mcp/utils/media/negotiator.py +243 -0
- amazon_ads_mcp/utils/media/types.py +199 -0
- amazon_ads_mcp/utils/openapi/__init__.py +16 -0
- amazon_ads_mcp/utils/openapi/json.py +55 -0
- amazon_ads_mcp/utils/openapi/loader.py +263 -0
- amazon_ads_mcp/utils/openapi/refs.py +46 -0
- amazon_ads_mcp/utils/region_config.py +200 -0
- amazon_ads_mcp/utils/response_wrapper.py +171 -0
- amazon_ads_mcp/utils/sampling_helpers.py +156 -0
- amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
- amazon_ads_mcp/utils/security.py +630 -0
- amazon_ads_mcp/utils/tool_naming.py +137 -0
- amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
- amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
- amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
- amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
- amazon_ads_mcp-0.2.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""Enhanced HTTP client for Amazon Ads API authentication.
|
|
2
|
+
|
|
3
|
+
This module provides an authenticated HTTP client that manages headers
|
|
4
|
+
for Amazon Advertising API calls. The client automatically handles
|
|
5
|
+
authentication, regional routing, and content negotiation.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
|
|
9
|
+
- Automatic authentication header injection
|
|
10
|
+
- Regional endpoint routing based on identity/marketplace
|
|
11
|
+
- Media type negotiation with OpenAPI specs
|
|
12
|
+
- Header scrubbing to remove conflicting headers
|
|
13
|
+
- Special handling for different API endpoints
|
|
14
|
+
- Response shaping for large AMC payloads
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
>>> client = AuthenticatedClient(auth_manager=auth_mgr)
|
|
18
|
+
>>> response = await client.get("/v2/profiles")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from contextvars import ContextVar
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any, Dict, List, Optional
|
|
27
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from ..config.settings import Settings
|
|
32
|
+
from ..utils.export_content_type_resolver import (
|
|
33
|
+
resolve_download_accept_headers,
|
|
34
|
+
)
|
|
35
|
+
from ..utils.header_resolver import HeaderNameResolver
|
|
36
|
+
from ..utils.media import MediaTypeRegistry
|
|
37
|
+
from ..utils.region_config import RegionConfig
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Context-local routing overrides/state to avoid cross-request leakage
|
|
42
|
+
_REGION_OVERRIDE_VAR: ContextVar[Optional[str]] = ContextVar(
|
|
43
|
+
"amazon_ads_region_override", default=None
|
|
44
|
+
)
|
|
45
|
+
# Marketplace override removed - deprecated functionality
|
|
46
|
+
_ROUTING_STATE_VAR: ContextVar[Dict[str, Any]] = ContextVar(
|
|
47
|
+
"amazon_ads_routing_state", default={}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthenticatedClient(httpx.AsyncClient):
|
|
52
|
+
"""Enhanced HTTP client that manages Amazon Ads API authentication headers.
|
|
53
|
+
|
|
54
|
+
This client extends httpx.AsyncClient to provide automatic header management for
|
|
55
|
+
Amazon Advertising API calls. It handles header scrubbing, injection, and media
|
|
56
|
+
type negotiation.
|
|
57
|
+
|
|
58
|
+
The client intercepts all HTTP requests and performs:
|
|
59
|
+
|
|
60
|
+
1. Media type negotiation based on OpenAPI specifications
|
|
61
|
+
2. Removal of conflicting or polluted headers
|
|
62
|
+
3. Injection of proper Amazon Ads authentication headers
|
|
63
|
+
4. Regional endpoint routing based on identity/marketplace
|
|
64
|
+
5. Special handling for different API endpoint families
|
|
65
|
+
|
|
66
|
+
Key Features:
|
|
67
|
+
|
|
68
|
+
- Removes polluted headers from FastMCP
|
|
69
|
+
- Injects correct Amazon authentication headers
|
|
70
|
+
- Handles media type negotiation
|
|
71
|
+
- Manages client ID fallbacks
|
|
72
|
+
- Supports profile-specific header rules
|
|
73
|
+
- Automatic regional endpoint routing
|
|
74
|
+
- Response shaping for large AMC responses
|
|
75
|
+
|
|
76
|
+
:param auth_manager: Authentication manager for header generation
|
|
77
|
+
:type auth_manager: Optional[AuthManager]
|
|
78
|
+
:param media_registry: Registry for content type negotiation
|
|
79
|
+
:type media_registry: Optional[MediaTypeRegistry]
|
|
80
|
+
:param header_resolver: Resolver for header name variations
|
|
81
|
+
:type header_resolver: Optional[HeaderNameResolver]
|
|
82
|
+
:raises httpx.RequestError: When required auth headers are missing
|
|
83
|
+
|
|
84
|
+
.. example::
|
|
85
|
+
>>> auth_mgr = get_auth_manager()
|
|
86
|
+
>>> client = AuthenticatedClient(auth_manager=auth_mgr)
|
|
87
|
+
>>> response = await client.get("/v2/campaigns")
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# anything we consider "polluted" and must remove if present
|
|
91
|
+
_FORBID_SUBSTRS = (
|
|
92
|
+
"authorization", # bearer from MCP client
|
|
93
|
+
"amazon-ads-clientid",
|
|
94
|
+
"amazon-advertising-api-clientid",
|
|
95
|
+
"amazon-advertising-api-scope",
|
|
96
|
+
"amazon-ads-accountid",
|
|
97
|
+
"x-amz-access-token",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*args,
|
|
103
|
+
auth_manager=None,
|
|
104
|
+
media_registry=None,
|
|
105
|
+
header_resolver=None,
|
|
106
|
+
**kwargs,
|
|
107
|
+
):
|
|
108
|
+
super().__init__(*args, **kwargs)
|
|
109
|
+
self.auth_manager = auth_manager
|
|
110
|
+
self.media_registry: Optional[MediaTypeRegistry] = media_registry
|
|
111
|
+
self.header_resolver: HeaderNameResolver = (
|
|
112
|
+
header_resolver or HeaderNameResolver()
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def send(self, request: httpx.Request, **kwargs) -> httpx.Response:
|
|
116
|
+
"""Single interception point for all HTTP requests.
|
|
117
|
+
|
|
118
|
+
This method is called for ALL requests, whether from:
|
|
119
|
+
- Direct client.request() calls (which build a Request then call send)
|
|
120
|
+
- FastMCP's OpenAPITool (which builds a Request then calls send)
|
|
121
|
+
|
|
122
|
+
It handles:
|
|
123
|
+
1. Media type negotiation based on OpenAPI specs
|
|
124
|
+
2. Header scrubbing to remove polluted auth headers
|
|
125
|
+
3. Injection of correct Amazon authentication headers
|
|
126
|
+
4. Special handling for profiles API endpoints
|
|
127
|
+
|
|
128
|
+
:param request: The HTTP request to send
|
|
129
|
+
:type request: httpx.Request
|
|
130
|
+
:param kwargs: Additional arguments to pass to the parent send method
|
|
131
|
+
:type kwargs: Dict[str, Any]
|
|
132
|
+
:return: The HTTP response
|
|
133
|
+
:rtype: httpx.Response
|
|
134
|
+
:raises httpx.RequestError: When required auth headers are missing
|
|
135
|
+
"""
|
|
136
|
+
# Check if already processed (idempotent) - CRITICAL for preventing double injection
|
|
137
|
+
if request.extensions.get("auth_injected"):
|
|
138
|
+
logger.debug(
|
|
139
|
+
f"Request already processed, skipping injection: {request.method} {request.url}"
|
|
140
|
+
)
|
|
141
|
+
return await super().send(request, **kwargs)
|
|
142
|
+
|
|
143
|
+
# Mark as processing to prevent concurrent/recursive injection
|
|
144
|
+
request.extensions["auth_injected"] = True
|
|
145
|
+
|
|
146
|
+
# Log incoming request for debugging (only once)
|
|
147
|
+
logger.debug(f"=== SEND: {request.method} {request.url}")
|
|
148
|
+
logger.debug(f" Headers before injection: {list(request.headers.keys())}")
|
|
149
|
+
|
|
150
|
+
# Inject headers (only once)
|
|
151
|
+
await self._inject_headers(request)
|
|
152
|
+
|
|
153
|
+
logger.debug(f"Headers injected for {request.method} {request.url}")
|
|
154
|
+
logger.debug(f" Headers after injection: {list(request.headers.keys())}")
|
|
155
|
+
|
|
156
|
+
# Log critical headers for debugging
|
|
157
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
158
|
+
logger.debug(f" Accept: {request.headers.get('accept', 'NOT SET')}")
|
|
159
|
+
logger.debug(
|
|
160
|
+
f" Content-Type: {request.headers.get('content-type', 'NOT SET')}"
|
|
161
|
+
)
|
|
162
|
+
# Verify auth header is present
|
|
163
|
+
if "authorization" in request.headers:
|
|
164
|
+
auth_val = request.headers["authorization"]
|
|
165
|
+
logger.debug(
|
|
166
|
+
f" Authorization present: Bearer [{len(auth_val) - 7} chars]"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Log the actual request headers right before sending
|
|
170
|
+
logger.debug("=== ACTUAL REQUEST BEING SENT ===")
|
|
171
|
+
logger.debug(f"URL: {request.url}")
|
|
172
|
+
logger.debug("Headers:")
|
|
173
|
+
for k, v in request.headers.items():
|
|
174
|
+
if k.lower() in [
|
|
175
|
+
"authorization",
|
|
176
|
+
"amazon-advertising-api-clientid",
|
|
177
|
+
]:
|
|
178
|
+
logger.debug(f" {k}: [REDACTED]")
|
|
179
|
+
else:
|
|
180
|
+
logger.debug(f" {k}: {v}")
|
|
181
|
+
|
|
182
|
+
# Call parent's send
|
|
183
|
+
resp = await super().send(request, **kwargs)
|
|
184
|
+
|
|
185
|
+
# Best-effort fallback shaping for AMC endpoints when FastMCP transforms are unavailable
|
|
186
|
+
try:
|
|
187
|
+
ct = (resp.headers.get("Content-Type") or "").lower()
|
|
188
|
+
if (
|
|
189
|
+
"application/json" in ct
|
|
190
|
+
or "/json" in ct
|
|
191
|
+
or ct.startswith("application/vnd.")
|
|
192
|
+
):
|
|
193
|
+
shaped = self._maybe_shape_amc_response(request, resp)
|
|
194
|
+
if shaped is not None:
|
|
195
|
+
# Create new response with shaped content (avoid _content manipulation)
|
|
196
|
+
payload = json.dumps(shaped, ensure_ascii=False).encode("utf-8")
|
|
197
|
+
|
|
198
|
+
# Build new response object
|
|
199
|
+
resp = httpx.Response(
|
|
200
|
+
status_code=resp.status_code,
|
|
201
|
+
headers=dict(resp.headers),
|
|
202
|
+
content=payload,
|
|
203
|
+
request=request,
|
|
204
|
+
)
|
|
205
|
+
resp.headers["content-length"] = str(len(payload))
|
|
206
|
+
except Exception as e:
|
|
207
|
+
# Log but don't fail
|
|
208
|
+
logger.debug(f"AMC response shaping failed: {e}")
|
|
209
|
+
|
|
210
|
+
return resp
|
|
211
|
+
|
|
212
|
+
def _maybe_shape_amc_response(
|
|
213
|
+
self, request: httpx.Request, response: httpx.Response
|
|
214
|
+
) -> Optional[dict]:
|
|
215
|
+
"""Truncate ONLY large AMC (Analytics/Measurement) JSON responses.
|
|
216
|
+
|
|
217
|
+
This is a runtime fallback for environments where FastMCP's
|
|
218
|
+
transform_tool is not supported, ensuring clients don't receive
|
|
219
|
+
extremely large AMC payloads in chat context.
|
|
220
|
+
|
|
221
|
+
IMPORTANT: This should NOT apply to regular API endpoints like
|
|
222
|
+
/v2/profiles, /v2/campaigns, etc. as they have proper pagination
|
|
223
|
+
and clients need the full data.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
method = (request.method or "").upper()
|
|
227
|
+
if method not in ("GET", "POST", "PUT"):
|
|
228
|
+
return None
|
|
229
|
+
url = str(request.url)
|
|
230
|
+
path = urlparse(url).path or ""
|
|
231
|
+
data = response.json()
|
|
232
|
+
|
|
233
|
+
# Only shape dict/array JSON
|
|
234
|
+
if not isinstance(data, (dict, list)):
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
# Determine cap based on endpoint family
|
|
238
|
+
cap = None
|
|
239
|
+
p = path.lower()
|
|
240
|
+
if "/amc/reporting/" in p:
|
|
241
|
+
if p.endswith("/datasources") and method == "GET":
|
|
242
|
+
cap = 3
|
|
243
|
+
elif "/datasources/" in p and method == "GET":
|
|
244
|
+
cap = 5
|
|
245
|
+
elif p.endswith("/workflows") and method == "GET":
|
|
246
|
+
cap = 10
|
|
247
|
+
elif "/workflowexecutions" in p and method == "GET":
|
|
248
|
+
cap = 10
|
|
249
|
+
else:
|
|
250
|
+
cap = 10
|
|
251
|
+
elif "/amc/audiences/" in p:
|
|
252
|
+
if "/connections" in p and method == "GET":
|
|
253
|
+
cap = 10
|
|
254
|
+
elif "/metadata/" in p and method == "GET":
|
|
255
|
+
cap = 10
|
|
256
|
+
elif "/records/" in p and method == "GET":
|
|
257
|
+
cap = 10
|
|
258
|
+
elif "/query" in p and method == "GET":
|
|
259
|
+
cap = 10
|
|
260
|
+
else:
|
|
261
|
+
cap = 10
|
|
262
|
+
|
|
263
|
+
# DO NOT shape regular v2 API endpoints - they have proper pagination
|
|
264
|
+
# Response shaping should ONLY apply to known large AMC responses
|
|
265
|
+
# that don't have good pagination support
|
|
266
|
+
|
|
267
|
+
if cap is None:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
return self._truncate_lists(data, cap)
|
|
271
|
+
except Exception:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def _truncate_lists(self, data: Any, n: int) -> Any:
|
|
275
|
+
try:
|
|
276
|
+
|
|
277
|
+
def walk(obj: Any) -> Any:
|
|
278
|
+
if isinstance(obj, list):
|
|
279
|
+
return [walk(x) for x in obj[: max(0, n)]]
|
|
280
|
+
if isinstance(obj, dict):
|
|
281
|
+
return {k: walk(v) for k, v in obj.items()}
|
|
282
|
+
return obj
|
|
283
|
+
|
|
284
|
+
return walk(data)
|
|
285
|
+
except Exception:
|
|
286
|
+
return data
|
|
287
|
+
|
|
288
|
+
def _map_auth_headers_to_spec(self, auth_headers: Dict[str, str]) -> Dict[str, str]:
|
|
289
|
+
"""Map authentication headers to their OpenAPI spec preferred names.
|
|
290
|
+
|
|
291
|
+
This handles header name normalization, ensuring that various forms of
|
|
292
|
+
header names (e.g., Client-Id, ClientId) map to the preferred form from
|
|
293
|
+
the OpenAPI specification.
|
|
294
|
+
|
|
295
|
+
The mapping process consolidates header variants into the preferred
|
|
296
|
+
names defined by the header resolver, ensuring consistency across
|
|
297
|
+
different API endpoints and specifications.
|
|
298
|
+
|
|
299
|
+
:param auth_headers: Original authentication headers
|
|
300
|
+
:type auth_headers: Dict[str, str]
|
|
301
|
+
:return: Mapped headers using preferred names
|
|
302
|
+
:rtype: Dict[str, str]
|
|
303
|
+
|
|
304
|
+
.. example::
|
|
305
|
+
>>> headers = {"Client-Id": "abc123", "Scope": "456"}
|
|
306
|
+
>>> mapped = client._map_auth_headers_to_spec(headers)
|
|
307
|
+
>>> # Returns {"Amazon-Advertising-API-ClientId": "abc123", ...}
|
|
308
|
+
"""
|
|
309
|
+
out: Dict[str, str] = dict(auth_headers)
|
|
310
|
+
|
|
311
|
+
pref_client = (
|
|
312
|
+
self.header_resolver.prefer_client() or "Amazon-Advertising-API-ClientId"
|
|
313
|
+
)
|
|
314
|
+
pref_scope = (
|
|
315
|
+
self.header_resolver.prefer_scope() or "Amazon-Advertising-API-Scope"
|
|
316
|
+
)
|
|
317
|
+
pref_acct = self.header_resolver.prefer_account() or "Amazon-Ads-AccountId"
|
|
318
|
+
|
|
319
|
+
# Normalize to preferred keys when variants exist
|
|
320
|
+
def move_first(src_keys: List[str], dest_key: str) -> None:
|
|
321
|
+
for s in src_keys:
|
|
322
|
+
if s in out and out[s]:
|
|
323
|
+
out[dest_key] = out.pop(s)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
move_first(
|
|
327
|
+
[
|
|
328
|
+
"Amazon-Advertising-API-ClientId",
|
|
329
|
+
"Amazon-Ads-ClientId",
|
|
330
|
+
"Client-Id",
|
|
331
|
+
"ClientId",
|
|
332
|
+
],
|
|
333
|
+
pref_client,
|
|
334
|
+
)
|
|
335
|
+
move_first(
|
|
336
|
+
["Amazon-Advertising-API-Scope", "Amazon-Ads-Scope", "Scope"],
|
|
337
|
+
pref_scope,
|
|
338
|
+
)
|
|
339
|
+
move_first(
|
|
340
|
+
["Amazon-Ads-AccountId", "Account-Id", "AccountId"],
|
|
341
|
+
pref_acct,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
def _get_env_client_id(self, current_value: str = "") -> Optional[str]:
|
|
347
|
+
"""Get client ID from environment variables.
|
|
348
|
+
|
|
349
|
+
Prefers `AMAZON_AD_API_CLIENT_ID` (new naming) and falls back to
|
|
350
|
+
`AMAZON_ADS_CLIENT_ID` (legacy), returning the first non-empty value.
|
|
351
|
+
|
|
352
|
+
This method handles client ID resolution when the authentication
|
|
353
|
+
provider doesn't supply a client ID or provides a placeholder value
|
|
354
|
+
like "openbridge".
|
|
355
|
+
|
|
356
|
+
:param current_value: Current client ID value (empty or 'openbridge')
|
|
357
|
+
:type current_value: str
|
|
358
|
+
:return: Client ID from environment or None
|
|
359
|
+
:rtype: Optional[str]
|
|
360
|
+
|
|
361
|
+
.. note::
|
|
362
|
+
AMAZON_AD_API_CLIENT_ID takes precedence over AMAZON_ADS_CLIENT_ID
|
|
363
|
+
"""
|
|
364
|
+
preferred = os.getenv("AMAZON_AD_API_CLIENT_ID")
|
|
365
|
+
legacy = os.getenv("AMAZON_ADS_CLIENT_ID")
|
|
366
|
+
env_client_id = preferred or legacy
|
|
367
|
+
|
|
368
|
+
if env_client_id:
|
|
369
|
+
env_name = (
|
|
370
|
+
"AMAZON_AD_API_CLIENT_ID" if preferred else "AMAZON_ADS_CLIENT_ID"
|
|
371
|
+
)
|
|
372
|
+
if not current_value:
|
|
373
|
+
logger.info(f"Using {env_name} from environment")
|
|
374
|
+
else:
|
|
375
|
+
logger.info(
|
|
376
|
+
f"Replacing 'openbridge' placeholder with {env_name} from environment"
|
|
377
|
+
)
|
|
378
|
+
return env_client_id
|
|
379
|
+
|
|
380
|
+
# Nothing set in environment
|
|
381
|
+
if not current_value:
|
|
382
|
+
logger.debug(
|
|
383
|
+
"No ClientId provided and no environment variable set. "
|
|
384
|
+
"Set AMAZON_AD_API_CLIENT_ID (preferred) or AMAZON_ADS_CLIENT_ID."
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
logger.debug(
|
|
388
|
+
f"ClientId '{current_value}' provided but checking for env override"
|
|
389
|
+
)
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
async def _inject_headers(self, request: httpx.Request) -> None:
|
|
393
|
+
"""Inject authentication and media headers into a request.
|
|
394
|
+
|
|
395
|
+
Handles media negotiation, header scrubbing, auth header injection,
|
|
396
|
+
and profiles endpoint special-casing.
|
|
397
|
+
|
|
398
|
+
This method performs the core request transformation:
|
|
399
|
+
|
|
400
|
+
1. Media type negotiation using the media registry
|
|
401
|
+
2. Removal of polluted/conflicting headers
|
|
402
|
+
3. Regional endpoint routing based on marketplace/identity
|
|
403
|
+
4. Authentication header injection
|
|
404
|
+
5. Special handling for specific endpoints
|
|
405
|
+
|
|
406
|
+
:param request: The HTTP request to modify
|
|
407
|
+
:type request: httpx.Request
|
|
408
|
+
:raises httpx.RequestError: When authentication fails or is missing
|
|
409
|
+
|
|
410
|
+
.. note::
|
|
411
|
+
This method modifies the request object in-place
|
|
412
|
+
"""
|
|
413
|
+
method = request.method
|
|
414
|
+
url = str(request.url)
|
|
415
|
+
path = urlparse(url).path
|
|
416
|
+
|
|
417
|
+
# 1) MEDIA NEGOTIATION
|
|
418
|
+
if self.media_registry:
|
|
419
|
+
content_type, accepts = self.media_registry.resolve(method, url)
|
|
420
|
+
if content_type and method.lower() != "get":
|
|
421
|
+
request.headers["Content-Type"] = content_type
|
|
422
|
+
# Respect pre-existing Accept header from upstream transforms/tools
|
|
423
|
+
if accepts and "Accept" not in request.headers:
|
|
424
|
+
preferred = next(
|
|
425
|
+
(a for a in accepts if a.startswith("application/vnd.")),
|
|
426
|
+
accepts[0],
|
|
427
|
+
)
|
|
428
|
+
request.headers["Accept"] = preferred
|
|
429
|
+
|
|
430
|
+
# Heuristic Accept override for known download/report endpoints
|
|
431
|
+
if (
|
|
432
|
+
"Accept" not in request.headers
|
|
433
|
+
or (request.headers.get("Accept") or "").strip() == "*/*"
|
|
434
|
+
):
|
|
435
|
+
try:
|
|
436
|
+
overrides = resolve_download_accept_headers(method, url)
|
|
437
|
+
if overrides:
|
|
438
|
+
if self.media_registry:
|
|
439
|
+
# Intersect with available accepts if we have them
|
|
440
|
+
_, accepts = self.media_registry.resolve(method, url)
|
|
441
|
+
if accepts:
|
|
442
|
+
for ct in overrides:
|
|
443
|
+
if ct in accepts:
|
|
444
|
+
request.headers["Accept"] = ct
|
|
445
|
+
break
|
|
446
|
+
else:
|
|
447
|
+
request.headers["Accept"] = overrides[0]
|
|
448
|
+
else:
|
|
449
|
+
request.headers["Accept"] = overrides[0]
|
|
450
|
+
else:
|
|
451
|
+
request.headers["Accept"] = overrides[0]
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.debug("Accept override skipped: %s", e)
|
|
454
|
+
|
|
455
|
+
# 2) STRIP POLLUTED HEADERS
|
|
456
|
+
removed = []
|
|
457
|
+
for key in list(request.headers.keys()):
|
|
458
|
+
if any(s in key.lower() for s in self._FORBID_SUBSTRS):
|
|
459
|
+
removed.append(key)
|
|
460
|
+
del request.headers[key]
|
|
461
|
+
if removed:
|
|
462
|
+
logger.debug("🧹 Scrubbed headers from request: %s", removed)
|
|
463
|
+
|
|
464
|
+
# 2a) Dynamic region routing for Amazon Ads endpoints based on marketplace/profile
|
|
465
|
+
try:
|
|
466
|
+
p = path or ""
|
|
467
|
+
# Apply to all Amazon Ads API paths: /v2/, /reporting/, /amc/, etc.
|
|
468
|
+
if p.startswith("/v2/") or p.startswith("/reporting/") or "/amc/" in p:
|
|
469
|
+
# Determine desired region: explicit override header, marketplace mapping, or env
|
|
470
|
+
hdr = request.headers
|
|
471
|
+
# Marketplace override removed - deprecated functionality
|
|
472
|
+
# 1) Explicit session override or env
|
|
473
|
+
region_override = (
|
|
474
|
+
(
|
|
475
|
+
(_REGION_OVERRIDE_VAR.get() or "")
|
|
476
|
+
or os.getenv("ADS_REGION_OVERRIDE", "")
|
|
477
|
+
)
|
|
478
|
+
.strip()
|
|
479
|
+
.lower()
|
|
480
|
+
)
|
|
481
|
+
region = (
|
|
482
|
+
region_override if region_override in {"na", "eu", "fe"} else None
|
|
483
|
+
)
|
|
484
|
+
source = None
|
|
485
|
+
if region:
|
|
486
|
+
source = "override"
|
|
487
|
+
|
|
488
|
+
# 2) Map marketplace → region if provided
|
|
489
|
+
mp = hdr.get("Amazon-Advertising-API-MarketplaceId") or hdr.get(
|
|
490
|
+
"Amazon-Ads-MarketplaceId"
|
|
491
|
+
)
|
|
492
|
+
if not region and mp:
|
|
493
|
+
mp = mp.strip()
|
|
494
|
+
eu_mps = {
|
|
495
|
+
"A1PA6795UKMFR9", # DE
|
|
496
|
+
"A1F83G8C2ARO7P", # UK
|
|
497
|
+
"A13V1IB3VIYZZH", # FR
|
|
498
|
+
"APJ6JRA9NG5V4", # IT
|
|
499
|
+
"A1RKKUPIHCS9HS", # ES
|
|
500
|
+
"A1805IZSGTT6HS", # NL
|
|
501
|
+
"A2NODRKZP88ZB9", # SE
|
|
502
|
+
"A1C3SOZRARQ6R3", # PL
|
|
503
|
+
}
|
|
504
|
+
na_mps = {
|
|
505
|
+
"ATVPDKIKX0DER", # US
|
|
506
|
+
"A2EUQ1WTGCTBG2", # CA
|
|
507
|
+
"A1AM78C64UM0Y8", # MX
|
|
508
|
+
}
|
|
509
|
+
fe_mps = {
|
|
510
|
+
"A1VC38T7YXB528", # JP
|
|
511
|
+
"A39IBJ37TRP1C6", # AU
|
|
512
|
+
"A19VAU5U5O7RUS", # SG
|
|
513
|
+
}
|
|
514
|
+
if mp in eu_mps:
|
|
515
|
+
region = "eu"
|
|
516
|
+
source = "marketplace"
|
|
517
|
+
elif mp in na_mps:
|
|
518
|
+
region = "na"
|
|
519
|
+
source = "marketplace"
|
|
520
|
+
elif mp in fe_mps:
|
|
521
|
+
region = "fe"
|
|
522
|
+
source = "marketplace"
|
|
523
|
+
|
|
524
|
+
# 3) Active identity region
|
|
525
|
+
if not region and self.auth_manager:
|
|
526
|
+
try:
|
|
527
|
+
active_identity = self.auth_manager.get_active_identity()
|
|
528
|
+
logger.debug(
|
|
529
|
+
f"Auth manager active identity: {active_identity.id if active_identity else None}"
|
|
530
|
+
)
|
|
531
|
+
ident_region = self.auth_manager.get_active_region()
|
|
532
|
+
logger.debug(f"Auth manager active region: {ident_region}")
|
|
533
|
+
if ident_region:
|
|
534
|
+
region = ident_region
|
|
535
|
+
source = "identity"
|
|
536
|
+
except Exception as e:
|
|
537
|
+
logger.debug(f"Failed to get active region: {e}")
|
|
538
|
+
|
|
539
|
+
# 4) If still unknown, fall back to configured settings region
|
|
540
|
+
if not region:
|
|
541
|
+
try:
|
|
542
|
+
region = Settings().amazon_ads_region
|
|
543
|
+
except Exception:
|
|
544
|
+
region = "na"
|
|
545
|
+
source = source or "fallback"
|
|
546
|
+
|
|
547
|
+
# Compute desired host
|
|
548
|
+
host = RegionConfig.get_api_host(region)
|
|
549
|
+
if Settings().amazon_ads_sandbox_mode:
|
|
550
|
+
host = host.replace("advertising-api", "advertising-api-test")
|
|
551
|
+
|
|
552
|
+
# Rewrite request URL host if different
|
|
553
|
+
u = urlparse(str(request.url))
|
|
554
|
+
if u.netloc and u.netloc != host:
|
|
555
|
+
new_url = urlunparse(
|
|
556
|
+
(u.scheme, host, u.path, u.params, u.query, u.fragment)
|
|
557
|
+
)
|
|
558
|
+
request.url = httpx.URL(new_url)
|
|
559
|
+
# IMPORTANT: Also update the Host header to match the new URL
|
|
560
|
+
request.headers["host"] = host
|
|
561
|
+
_ROUTING_STATE_VAR.set(
|
|
562
|
+
{
|
|
563
|
+
"override": _REGION_OVERRIDE_VAR.get(),
|
|
564
|
+
"source": source,
|
|
565
|
+
"region": region,
|
|
566
|
+
"host": host,
|
|
567
|
+
"marketplace": None, # Deprecated
|
|
568
|
+
}
|
|
569
|
+
)
|
|
570
|
+
logger.debug(
|
|
571
|
+
"Region routing decided: source=%s region=%s host=%s",
|
|
572
|
+
source,
|
|
573
|
+
region,
|
|
574
|
+
host,
|
|
575
|
+
)
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
# 2b) Normalize AMC time query params to expected ISO format (no timezone suffix)
|
|
580
|
+
try:
|
|
581
|
+
p_lower = (path or "").lower()
|
|
582
|
+
if "/amc/" in p_lower and "/workflowexecutions" in p_lower:
|
|
583
|
+
url_obj = urlparse(url)
|
|
584
|
+
q = dict(parse_qsl(url_obj.query, keep_blank_values=True))
|
|
585
|
+
|
|
586
|
+
def to_amc_iso(val: str) -> str:
|
|
587
|
+
s = (val or "").strip()
|
|
588
|
+
if not s:
|
|
589
|
+
return s
|
|
590
|
+
# numeric seconds or ms -> convert to ISO 'YYYY-MM-DDTHH:MM:SS'
|
|
591
|
+
if s.isdigit():
|
|
592
|
+
n = int(s)
|
|
593
|
+
# scale seconds to ms if needed, then to UTC ISO (no 'Z')
|
|
594
|
+
if n < 10**12:
|
|
595
|
+
n *= 1000
|
|
596
|
+
dt = datetime.fromtimestamp(n / 1000, tz=timezone.utc)
|
|
597
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
598
|
+
# ISO formats and dates -> normalize to no-suffix ISO
|
|
599
|
+
try:
|
|
600
|
+
iso = s
|
|
601
|
+
if iso.endswith("Z"):
|
|
602
|
+
iso = iso[:-1] + "+00:00"
|
|
603
|
+
if len(iso) == 10 and iso.count("-") == 2:
|
|
604
|
+
iso = iso + "T00:00:00+00:00"
|
|
605
|
+
if len(s) == 8 and s.isdigit():
|
|
606
|
+
iso = f"{s[0:4]}-{s[4:6]}-{s[6:8]}T00:00:00+00:00"
|
|
607
|
+
dt = datetime.fromisoformat(iso)
|
|
608
|
+
if dt.tzinfo is None:
|
|
609
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
610
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
611
|
+
except Exception:
|
|
612
|
+
return s
|
|
613
|
+
|
|
614
|
+
changed = False
|
|
615
|
+
for key in (
|
|
616
|
+
"minCreationTime",
|
|
617
|
+
"maxCreationTime",
|
|
618
|
+
"startTime",
|
|
619
|
+
"endTime",
|
|
620
|
+
):
|
|
621
|
+
if key in q:
|
|
622
|
+
newv = to_amc_iso(q[key])
|
|
623
|
+
if newv != q[key]:
|
|
624
|
+
q[key] = newv
|
|
625
|
+
changed = True
|
|
626
|
+
if changed:
|
|
627
|
+
new_query = urlencode(q, doseq=True)
|
|
628
|
+
new_url = urlunparse(
|
|
629
|
+
(
|
|
630
|
+
url_obj.scheme,
|
|
631
|
+
url_obj.netloc,
|
|
632
|
+
url_obj.path,
|
|
633
|
+
url_obj.params,
|
|
634
|
+
new_query,
|
|
635
|
+
url_obj.fragment,
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
request.url = httpx.URL(new_url)
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
|
|
642
|
+
# 3) AUTH-AWARE REGIONAL ENDPOINT FIX AND CORRECT AUTH HEADERS (only for Amazon Ads API calls)
|
|
643
|
+
# Get the current URL (may have been modified by previous steps)
|
|
644
|
+
current_url = str(request.url)
|
|
645
|
+
parsed_url = urlparse(current_url)
|
|
646
|
+
u_host = (parsed_url.hostname or parsed_url.netloc or "").lower()
|
|
647
|
+
# Validate hostname is legitimate Amazon domain
|
|
648
|
+
is_amazon_ads_domain = (
|
|
649
|
+
"advertising-api" in u_host
|
|
650
|
+
and (u_host.endswith(".amazon.com") or u_host == "amazon.com")
|
|
651
|
+
)
|
|
652
|
+
is_ads_api = (
|
|
653
|
+
is_amazon_ads_domain
|
|
654
|
+
or path.startswith("/v2/")
|
|
655
|
+
or path.startswith("/reporting/")
|
|
656
|
+
or ("/amc/" in path)
|
|
657
|
+
)
|
|
658
|
+
if is_ads_api:
|
|
659
|
+
# Auth-aware URL rewriting
|
|
660
|
+
if self.auth_manager:
|
|
661
|
+
# Check if provider requires identity-based region routing
|
|
662
|
+
requires_identity_routing = (
|
|
663
|
+
hasattr(
|
|
664
|
+
self.auth_manager.provider,
|
|
665
|
+
"requires_identity_region_routing",
|
|
666
|
+
)
|
|
667
|
+
and self.auth_manager.provider.requires_identity_region_routing()
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if requires_identity_routing:
|
|
671
|
+
# Provider requires routing to match identity's region (EVERY request)
|
|
672
|
+
identity = self.auth_manager.get_active_identity()
|
|
673
|
+
if identity:
|
|
674
|
+
identity_region = identity.attributes.get(
|
|
675
|
+
"region", "na"
|
|
676
|
+
).lower()
|
|
677
|
+
|
|
678
|
+
# Map region to correct endpoint
|
|
679
|
+
correct_host = RegionConfig.get_api_host(identity_region)
|
|
680
|
+
|
|
681
|
+
# ALWAYS update URL to match identity's region for this provider
|
|
682
|
+
parsed = urlparse(current_url)
|
|
683
|
+
if parsed.netloc != correct_host:
|
|
684
|
+
new_url = urlunparse(
|
|
685
|
+
(
|
|
686
|
+
parsed.scheme,
|
|
687
|
+
correct_host,
|
|
688
|
+
parsed.path,
|
|
689
|
+
parsed.params,
|
|
690
|
+
parsed.query,
|
|
691
|
+
parsed.fragment,
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
request.url = httpx.URL(new_url)
|
|
695
|
+
# Also update Host header to match
|
|
696
|
+
request.headers["host"] = correct_host
|
|
697
|
+
logger.debug(
|
|
698
|
+
f"{self.auth_manager.provider.provider_type}: Routing request to {identity_region.upper()} endpoint: {correct_host} (identity: {identity.id})"
|
|
699
|
+
)
|
|
700
|
+
else:
|
|
701
|
+
logger.warning(
|
|
702
|
+
f"{self.auth_manager.provider.provider_type}: No active identity set, cannot determine correct region"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
else:
|
|
706
|
+
# DIRECT AUTH: Do NOT rewrite host unless explicit override is set
|
|
707
|
+
region_override = _REGION_OVERRIDE_VAR.get()
|
|
708
|
+
if region_override and region_override in {
|
|
709
|
+
"na",
|
|
710
|
+
"eu",
|
|
711
|
+
"fe",
|
|
712
|
+
}:
|
|
713
|
+
override_host = RegionConfig.get_api_host(region_override)
|
|
714
|
+
parsed = urlparse(current_url)
|
|
715
|
+
if parsed.netloc != override_host:
|
|
716
|
+
new_url = urlunparse(
|
|
717
|
+
(
|
|
718
|
+
parsed.scheme,
|
|
719
|
+
override_host,
|
|
720
|
+
parsed.path,
|
|
721
|
+
parsed.params,
|
|
722
|
+
parsed.query,
|
|
723
|
+
parsed.fragment,
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
request.url = httpx.URL(new_url)
|
|
727
|
+
request.headers["host"] = override_host
|
|
728
|
+
logger.debug(
|
|
729
|
+
f"Direct auth: Applied region override to {region_override.upper()}: {override_host}"
|
|
730
|
+
)
|
|
731
|
+
# Otherwise, trust the base URL set during initialization for Direct auth
|
|
732
|
+
|
|
733
|
+
# Get fresh auth headers for EVERY request (critical for OpenBridge)
|
|
734
|
+
auth_headers: Dict[str, str] = {}
|
|
735
|
+
if self.auth_manager is not None:
|
|
736
|
+
logger.info(f"Getting auth headers for request to {path}")
|
|
737
|
+
try:
|
|
738
|
+
headers = await self.auth_manager.get_headers()
|
|
739
|
+
logger.info(f"Got auth headers: {list(headers.keys())}")
|
|
740
|
+
auth_headers.update(headers)
|
|
741
|
+
except Exception as e:
|
|
742
|
+
# Do not send unauthenticated requests to Amazon Ads API
|
|
743
|
+
raise httpx.RequestError(
|
|
744
|
+
f"Authentication required: {e}. Set an active identity or configure authentication.",
|
|
745
|
+
request=request,
|
|
746
|
+
)
|
|
747
|
+
else:
|
|
748
|
+
raise httpx.RequestError(
|
|
749
|
+
"Authentication manager unavailable; cannot build auth headers.",
|
|
750
|
+
request=request,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# Map to spec-preferred names
|
|
754
|
+
auth_headers = self._map_auth_headers_to_spec(auth_headers)
|
|
755
|
+
|
|
756
|
+
# Handle missing client ID (should not happen with properly configured OpenBridge)
|
|
757
|
+
client_id = auth_headers.get("Amazon-Advertising-API-ClientId")
|
|
758
|
+
if not client_id:
|
|
759
|
+
# Try to get from environment as last resort
|
|
760
|
+
env_client_id = self._get_env_client_id(current_value="")
|
|
761
|
+
if env_client_id:
|
|
762
|
+
logger.warning(
|
|
763
|
+
"No client ID in auth headers, using environment variable"
|
|
764
|
+
)
|
|
765
|
+
auth_headers["Amazon-Advertising-API-ClientId"] = env_client_id
|
|
766
|
+
|
|
767
|
+
# Validate mandatory headers
|
|
768
|
+
if not (
|
|
769
|
+
auth_headers.get("Authorization")
|
|
770
|
+
or request.headers.get("Authorization")
|
|
771
|
+
):
|
|
772
|
+
raise httpx.RequestError(
|
|
773
|
+
"Missing Authorization header for Amazon Ads API request.",
|
|
774
|
+
request=request,
|
|
775
|
+
)
|
|
776
|
+
if not (
|
|
777
|
+
auth_headers.get("Amazon-Advertising-API-ClientId")
|
|
778
|
+
or request.headers.get("Amazon-Advertising-API-ClientId")
|
|
779
|
+
):
|
|
780
|
+
raise httpx.RequestError(
|
|
781
|
+
"Missing ClientId header. Set AMAZON_AD_API_CLIENT_ID (preferred) or AMAZON_ADS_CLIENT_ID, or ensure provider supplies ClientId.",
|
|
782
|
+
request=request,
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# Profiles endpoint: do not inject scope on root listing
|
|
786
|
+
if path.startswith("/v2/profiles") and path.strip("/") == "v2/profiles":
|
|
787
|
+
for k in [
|
|
788
|
+
"Amazon-Advertising-API-Scope",
|
|
789
|
+
"Amazon-Ads-AccountId",
|
|
790
|
+
]:
|
|
791
|
+
auth_headers.pop(k, None)
|
|
792
|
+
|
|
793
|
+
# Merge auth headers last
|
|
794
|
+
logger.info("Adding auth headers to request:")
|
|
795
|
+
for k, v in auth_headers.items():
|
|
796
|
+
if v:
|
|
797
|
+
request.headers[k] = v
|
|
798
|
+
if "authorization" in k.lower():
|
|
799
|
+
# Log Bearer prefix and token length for debugging
|
|
800
|
+
if v.startswith("Bearer "):
|
|
801
|
+
token_len = len(v) - 7 # Subtract "Bearer " length
|
|
802
|
+
logger.info(f" {k}: Bearer [token: {token_len} chars]")
|
|
803
|
+
else:
|
|
804
|
+
logger.warning(f" {k}: MISSING 'Bearer ' prefix! Value starts with: {v[:10]}...")
|
|
805
|
+
else:
|
|
806
|
+
logger.info(f" {k}: {v}")
|
|
807
|
+
|
|
808
|
+
# Log final headers (for debugging)
|
|
809
|
+
logger.debug("Final request headers:")
|
|
810
|
+
for k, v in request.headers.items():
|
|
811
|
+
if "authorization" in k.lower() or "token" in k.lower():
|
|
812
|
+
logger.debug(f" {k}: [REDACTED]")
|
|
813
|
+
else:
|
|
814
|
+
logger.debug(f" {k}: {v}")
|
|
815
|
+
|
|
816
|
+
# Ensure Accept header for JSON responses
|
|
817
|
+
if "Accept" not in request.headers:
|
|
818
|
+
request.headers["Accept"] = "application/json"
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# Export global state accessors for routing tools
|
|
822
|
+
def get_region_override() -> Optional[str]:
|
|
823
|
+
"""Get the current region override.
|
|
824
|
+
|
|
825
|
+
Returns the currently set region override from context-local storage.
|
|
826
|
+
The override affects endpoint routing for Amazon Ads API requests.
|
|
827
|
+
|
|
828
|
+
:return: Current region override ("na", "eu", "fe") or None
|
|
829
|
+
:rtype: Optional[str]
|
|
830
|
+
|
|
831
|
+
.. example::
|
|
832
|
+
>>> region = get_region_override()
|
|
833
|
+
>>> if region:
|
|
834
|
+
... print(f"Current region override: {region}")
|
|
835
|
+
"""
|
|
836
|
+
return _REGION_OVERRIDE_VAR.get()
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def set_region_override(region: Optional[str]) -> None:
|
|
840
|
+
"""Set the region override.
|
|
841
|
+
|
|
842
|
+
Sets a region override that affects endpoint routing for Amazon Ads API
|
|
843
|
+
requests. Valid values are "na", "eu", "fe", or None to clear.
|
|
844
|
+
|
|
845
|
+
:param region: Region code ("na", "eu", "fe") or None to clear
|
|
846
|
+
:type region: Optional[str]
|
|
847
|
+
|
|
848
|
+
.. example::
|
|
849
|
+
>>> set_region_override("eu") # Route to EU endpoints
|
|
850
|
+
>>> set_region_override(None) # Clear override
|
|
851
|
+
"""
|
|
852
|
+
_REGION_OVERRIDE_VAR.set(region)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# Marketplace override functions removed - deprecated functionality
|
|
856
|
+
# Use set_active_region() from tools/region.py instead
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def get_routing_state() -> Dict[str, Any]:
|
|
860
|
+
"""Get the current routing state.
|
|
861
|
+
|
|
862
|
+
Returns the complete routing state including region, source, host,
|
|
863
|
+
and marketplace information from the last request processed.
|
|
864
|
+
|
|
865
|
+
:return: Dictionary containing routing state information
|
|
866
|
+
:rtype: Dict[str, Any]
|
|
867
|
+
|
|
868
|
+
.. example::
|
|
869
|
+
>>> state = get_routing_state()
|
|
870
|
+
>>> print(f"Region: {state.get('region')}")
|
|
871
|
+
>>> print(f"Host: {state.get('host')}")
|
|
872
|
+
"""
|
|
873
|
+
return _ROUTING_STATE_VAR.get()
|