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,263 @@
|
|
|
1
|
+
"""Dynamic OpenAPI specification loader for Amazon Ads MCP server.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for loading, merging, and managing
|
|
4
|
+
OpenAPI specifications for the Amazon Ads API, enabling dynamic
|
|
5
|
+
API integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAPISpecLoader:
|
|
17
|
+
"""Load and merge OpenAPI specifications dynamically.
|
|
18
|
+
|
|
19
|
+
Handles loading of multiple OpenAPI specifications from a manifest
|
|
20
|
+
file and merging them into a single comprehensive specification
|
|
21
|
+
for the Amazon Ads API.
|
|
22
|
+
|
|
23
|
+
:param base_path: Base path for OpenAPI specifications
|
|
24
|
+
:type base_path: Path
|
|
25
|
+
:param manifest_path: Path to the manifest file
|
|
26
|
+
:type manifest_path: Path
|
|
27
|
+
:param specs: Dictionary of loaded specifications
|
|
28
|
+
:type specs: Dict[str, Any]
|
|
29
|
+
:param merged_spec: Cached merged specification
|
|
30
|
+
:type merged_spec: Optional[Dict[str, Any]]
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, base_path: Path = Path("openapi/amazon_ads_apis")):
|
|
34
|
+
self.base_path = base_path
|
|
35
|
+
self.manifest_path = base_path / "manifest.json"
|
|
36
|
+
self.specs = {}
|
|
37
|
+
self.merged_spec = None
|
|
38
|
+
|
|
39
|
+
def load_all_specs(self) -> Dict[str, Any]:
|
|
40
|
+
"""Load all OpenAPI specifications from the manifest.
|
|
41
|
+
|
|
42
|
+
Loads all OpenAPI specifications listed in the manifest file.
|
|
43
|
+
Falls back to legacy specifications if manifest is not found.
|
|
44
|
+
|
|
45
|
+
:return: Dictionary of loaded specifications
|
|
46
|
+
:rtype: Dict[str, Any]
|
|
47
|
+
"""
|
|
48
|
+
if not self.manifest_path.exists():
|
|
49
|
+
logger.warning(f"Manifest not found at {self.manifest_path}")
|
|
50
|
+
return self._load_legacy_specs()
|
|
51
|
+
|
|
52
|
+
with open(self.manifest_path) as f:
|
|
53
|
+
manifest = json.load(f)
|
|
54
|
+
|
|
55
|
+
logger.info(f"Loading {manifest['successful']} OpenAPI specifications")
|
|
56
|
+
|
|
57
|
+
# Load each successful spec
|
|
58
|
+
for spec_info in manifest["specs"]:
|
|
59
|
+
if spec_info["status"] == "success":
|
|
60
|
+
# Fix path - the manifest stores paths relative to openapi/
|
|
61
|
+
spec_path = self.base_path.parent / spec_info["file"]
|
|
62
|
+
if spec_path.exists():
|
|
63
|
+
try:
|
|
64
|
+
with open(spec_path) as f:
|
|
65
|
+
spec = json.load(f)
|
|
66
|
+
|
|
67
|
+
category = spec_info["category"]
|
|
68
|
+
resource = spec_info["resource"]
|
|
69
|
+
key = f"{category}/{resource}"
|
|
70
|
+
|
|
71
|
+
self.specs[key] = {"spec": spec, "info": spec_info}
|
|
72
|
+
logger.debug(f"Loaded {key}")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to load {spec_path}: {e}")
|
|
75
|
+
|
|
76
|
+
logger.info(f"Successfully loaded {len(self.specs)} specifications")
|
|
77
|
+
return self.specs
|
|
78
|
+
|
|
79
|
+
def _load_legacy_specs(self) -> Dict[str, Any]:
|
|
80
|
+
"""Load legacy manually downloaded specs as fallback.
|
|
81
|
+
|
|
82
|
+
Loads legacy OpenAPI specifications as a fallback when
|
|
83
|
+
the manifest file is not available.
|
|
84
|
+
|
|
85
|
+
:return: Dictionary of loaded legacy specifications
|
|
86
|
+
:rtype: Dict[str, Any]
|
|
87
|
+
"""
|
|
88
|
+
legacy_specs = {
|
|
89
|
+
"test_accounts": "openapi/test_account.json",
|
|
90
|
+
"profiles": "openapi/profiles.json",
|
|
91
|
+
"exports": "openapi/exports.json",
|
|
92
|
+
"ads_api": "openapi/amazon_ads_all.json",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for name, path in legacy_specs.items():
|
|
96
|
+
spec_path = Path(path)
|
|
97
|
+
if spec_path.exists():
|
|
98
|
+
try:
|
|
99
|
+
with open(spec_path) as f:
|
|
100
|
+
spec = json.load(f)
|
|
101
|
+
self.specs[name] = {
|
|
102
|
+
"spec": spec,
|
|
103
|
+
"info": {"resource": name, "category": "legacy"},
|
|
104
|
+
}
|
|
105
|
+
logger.info(f"Loaded legacy spec: {name}")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Failed to load legacy spec {path}: {e}")
|
|
108
|
+
|
|
109
|
+
return self.specs
|
|
110
|
+
|
|
111
|
+
def merge_specs(self) -> Dict[str, Any]:
|
|
112
|
+
"""Merge all loaded specs into a single OpenAPI specification.
|
|
113
|
+
|
|
114
|
+
Combines all loaded OpenAPI specifications into a single
|
|
115
|
+
comprehensive specification for the Amazon Ads API.
|
|
116
|
+
|
|
117
|
+
:return: Merged OpenAPI specification
|
|
118
|
+
:rtype: Dict[str, Any]
|
|
119
|
+
"""
|
|
120
|
+
if self.merged_spec:
|
|
121
|
+
return self.merged_spec
|
|
122
|
+
|
|
123
|
+
# Base structure
|
|
124
|
+
merged = {
|
|
125
|
+
"openapi": "3.0.1",
|
|
126
|
+
"info": {
|
|
127
|
+
"title": "Amazon Ads API - Complete",
|
|
128
|
+
"version": "1.0",
|
|
129
|
+
"description": "Comprehensive Amazon Ads API including all services",
|
|
130
|
+
},
|
|
131
|
+
"servers": [
|
|
132
|
+
{
|
|
133
|
+
"url": "https://advertising-api.amazon.com",
|
|
134
|
+
"description": "North America",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"url": "https://advertising-api-eu.amazon.com",
|
|
138
|
+
"description": "Europe",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"url": "https://advertising-api-fe.amazon.com",
|
|
142
|
+
"description": "Far East",
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
"paths": {},
|
|
146
|
+
"components": {
|
|
147
|
+
"schemas": {},
|
|
148
|
+
"securitySchemes": {
|
|
149
|
+
"bearerAuth": {
|
|
150
|
+
"type": "http",
|
|
151
|
+
"scheme": "bearer",
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"parameters": {},
|
|
155
|
+
"responses": {},
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Merge paths and components from each spec
|
|
160
|
+
for key, spec_data in self.specs.items():
|
|
161
|
+
spec = spec_data["spec"]
|
|
162
|
+
|
|
163
|
+
# Merge paths
|
|
164
|
+
if "paths" in spec:
|
|
165
|
+
for path, path_item in spec["paths"].items():
|
|
166
|
+
# Process each path item to remove auth headers
|
|
167
|
+
processed_path_item = self._remove_auth_headers(path_item)
|
|
168
|
+
|
|
169
|
+
if path not in merged["paths"]:
|
|
170
|
+
merged["paths"][path] = processed_path_item
|
|
171
|
+
else:
|
|
172
|
+
# Merge operations if path already exists
|
|
173
|
+
for method, operation in processed_path_item.items():
|
|
174
|
+
if method not in merged["paths"][path]:
|
|
175
|
+
merged["paths"][path][method] = operation
|
|
176
|
+
|
|
177
|
+
# Merge components
|
|
178
|
+
if "components" in spec:
|
|
179
|
+
for component_type in [
|
|
180
|
+
"schemas",
|
|
181
|
+
"parameters",
|
|
182
|
+
"responses",
|
|
183
|
+
"examples",
|
|
184
|
+
"requestBodies",
|
|
185
|
+
"headers",
|
|
186
|
+
]:
|
|
187
|
+
if component_type in spec["components"]:
|
|
188
|
+
if component_type not in merged["components"]:
|
|
189
|
+
merged["components"][component_type] = {}
|
|
190
|
+
|
|
191
|
+
# Add prefix to avoid conflicts
|
|
192
|
+
prefix = key.replace("/", "_").replace(" ", "_")
|
|
193
|
+
for name, component in spec["components"][
|
|
194
|
+
component_type
|
|
195
|
+
].items():
|
|
196
|
+
# Use original name if no conflict, otherwise prefix it
|
|
197
|
+
final_name = name
|
|
198
|
+
if name in merged["components"][component_type]:
|
|
199
|
+
final_name = f"{prefix}_{name}"
|
|
200
|
+
merged["components"][component_type][
|
|
201
|
+
final_name
|
|
202
|
+
] = component
|
|
203
|
+
|
|
204
|
+
self.merged_spec = merged
|
|
205
|
+
logger.info(
|
|
206
|
+
f"Merged {len(merged['paths'])} paths from {len(self.specs)} specifications"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return merged
|
|
210
|
+
|
|
211
|
+
def get_categories(self) -> Dict[str, List[str]]:
|
|
212
|
+
"""Get all categories and their resources."""
|
|
213
|
+
categories = {}
|
|
214
|
+
for key, spec_data in self.specs.items():
|
|
215
|
+
info = spec_data["info"]
|
|
216
|
+
category = info.get("category", "unknown")
|
|
217
|
+
resource = info.get("resource", key)
|
|
218
|
+
|
|
219
|
+
if category not in categories:
|
|
220
|
+
categories[category] = []
|
|
221
|
+
categories[category].append(resource)
|
|
222
|
+
|
|
223
|
+
return categories
|
|
224
|
+
|
|
225
|
+
def _remove_auth_headers(
|
|
226
|
+
self, path_item: Dict[str, Any]
|
|
227
|
+
) -> Dict[str, Any]:
|
|
228
|
+
"""Remove authentication headers from path item parameters."""
|
|
229
|
+
auth_headers = {
|
|
230
|
+
"Amazon-Advertising-API-ClientId",
|
|
231
|
+
"Amazon-Advertising-API-Scope",
|
|
232
|
+
"Authorization",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
processed = {}
|
|
236
|
+
for method, operation in path_item.items():
|
|
237
|
+
if method in ["get", "post", "put", "delete", "patch"]:
|
|
238
|
+
processed[method] = operation.copy()
|
|
239
|
+
|
|
240
|
+
# Remove auth headers from parameters
|
|
241
|
+
if "parameters" in operation:
|
|
242
|
+
processed[method]["parameters"] = [
|
|
243
|
+
param
|
|
244
|
+
for param in operation["parameters"]
|
|
245
|
+
if not (
|
|
246
|
+
param.get("in") == "header"
|
|
247
|
+
and param.get("name") in auth_headers
|
|
248
|
+
)
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
return processed
|
|
252
|
+
|
|
253
|
+
def save_merged_spec(self, output_path: Path):
|
|
254
|
+
"""Save the merged specification to a file."""
|
|
255
|
+
merged = self.merge_specs()
|
|
256
|
+
with open(output_path, "w") as f:
|
|
257
|
+
json.dump(merged, f, indent=2)
|
|
258
|
+
logger.info(f"Saved merged spec to {output_path}")
|
|
259
|
+
|
|
260
|
+
def load_and_merge_specs(self) -> Dict[str, Any]:
|
|
261
|
+
"""Load all specs and return the merged specification."""
|
|
262
|
+
self.load_all_specs()
|
|
263
|
+
return self.merge_specs()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Reference utilities for OpenAPI specs.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for handling OpenAPI specification references
|
|
4
|
+
($ref pointers). It includes functionality to dereference JSON Schema
|
|
5
|
+
references within OpenAPI documents, allowing access to the actual schema
|
|
6
|
+
definitions rather than just reference pointers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def deref(spec: dict, obj: Optional[dict]) -> Optional[dict]:
|
|
13
|
+
"""Dereference a $ref pointer in an OpenAPI spec.
|
|
14
|
+
|
|
15
|
+
This function resolves JSON Schema references ($ref) within OpenAPI
|
|
16
|
+
specifications. It follows the reference path from the root of the
|
|
17
|
+
specification to retrieve the actual schema object that the reference
|
|
18
|
+
points to.
|
|
19
|
+
|
|
20
|
+
The function handles relative references that start with "#/" and
|
|
21
|
+
traverses the specification structure using the path components.
|
|
22
|
+
If the reference cannot be resolved (invalid path, missing keys,
|
|
23
|
+
or non-dict target), it returns the original object unchanged.
|
|
24
|
+
|
|
25
|
+
:param spec: The complete OpenAPI specification dictionary
|
|
26
|
+
:type spec: dict
|
|
27
|
+
:param obj: Object that may contain a $ref pointer to dereference
|
|
28
|
+
:type obj: Optional[dict]
|
|
29
|
+
:return: Dereferenced object if successful, original object if
|
|
30
|
+
dereferencing fails or is not needed
|
|
31
|
+
:rtype: Optional[dict]
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(obj, dict):
|
|
34
|
+
return obj
|
|
35
|
+
ref = obj.get("$ref")
|
|
36
|
+
if not isinstance(ref, str) or not ref.startswith("#/"):
|
|
37
|
+
return obj
|
|
38
|
+
cur: Any = spec
|
|
39
|
+
for part in ref.lstrip("#/").split("/"):
|
|
40
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
41
|
+
return obj
|
|
42
|
+
cur = cur[part]
|
|
43
|
+
return cur if isinstance(cur, dict) else obj
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["deref"]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Centralized region configuration for Amazon Ads API.
|
|
2
|
+
|
|
3
|
+
This module provides a single source of truth for all region-specific
|
|
4
|
+
configurations including API endpoints, OAuth endpoints, and region metadata.
|
|
5
|
+
All region mappings should be defined here to avoid duplication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Literal, Optional
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
# Type alias for region codes
|
|
12
|
+
RegionCode = Literal["na", "eu", "fe"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegionConfig:
|
|
16
|
+
"""Centralized configuration for Amazon Ads API regions.
|
|
17
|
+
|
|
18
|
+
This class provides a single source of truth for all region-specific
|
|
19
|
+
endpoints and configurations. All components should use this class
|
|
20
|
+
instead of defining their own region mappings.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Single source of truth for API endpoints
|
|
24
|
+
API_ENDPOINTS: Dict[RegionCode, str] = {
|
|
25
|
+
"na": "https://advertising-api.amazon.com",
|
|
26
|
+
"eu": "https://advertising-api-eu.amazon.com",
|
|
27
|
+
"fe": "https://advertising-api-fe.amazon.com",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Single source of truth for OAuth token endpoints
|
|
31
|
+
OAUTH_ENDPOINTS: Dict[RegionCode, str] = {
|
|
32
|
+
"na": "https://api.amazon.com/auth/o2/token",
|
|
33
|
+
"eu": "https://api.amazon.co.uk/auth/o2/token",
|
|
34
|
+
"fe": "https://api.amazon.co.jp/auth/o2/token",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Host mappings for URL parsing (without https://)
|
|
38
|
+
API_HOSTS: Dict[RegionCode, str] = {
|
|
39
|
+
"na": "advertising-api.amazon.com",
|
|
40
|
+
"eu": "advertising-api-eu.amazon.com",
|
|
41
|
+
"fe": "advertising-api-fe.amazon.com",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Region display names for UI/logging
|
|
45
|
+
REGION_NAMES: Dict[RegionCode, str] = {
|
|
46
|
+
"na": "North America",
|
|
47
|
+
"eu": "Europe",
|
|
48
|
+
"fe": "Far East",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Default region if none specified
|
|
52
|
+
DEFAULT_REGION: RegionCode = "na"
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def get_api_endpoint(cls, region: Optional[str] = None) -> str:
|
|
56
|
+
"""Get API endpoint URL for the specified region.
|
|
57
|
+
|
|
58
|
+
:param region: Region code (na, eu, fe) or None for default
|
|
59
|
+
:type region: Optional[str]
|
|
60
|
+
:return: Full API endpoint URL
|
|
61
|
+
:rtype: str
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> RegionConfig.get_api_endpoint("eu")
|
|
65
|
+
'https://advertising-api-eu.amazon.com'
|
|
66
|
+
"""
|
|
67
|
+
if region is None:
|
|
68
|
+
region = cls.DEFAULT_REGION
|
|
69
|
+
region = region.lower()
|
|
70
|
+
return cls.API_ENDPOINTS.get(region, cls.API_ENDPOINTS[cls.DEFAULT_REGION])
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get_oauth_endpoint(cls, region: Optional[str] = None) -> str:
|
|
74
|
+
"""Get OAuth token endpoint URL for the specified region.
|
|
75
|
+
|
|
76
|
+
:param region: Region code (na, eu, fe) or None for default
|
|
77
|
+
:type region: Optional[str]
|
|
78
|
+
:return: Full OAuth token endpoint URL
|
|
79
|
+
:rtype: str
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
>>> RegionConfig.get_oauth_endpoint("fe")
|
|
83
|
+
'https://api.amazon.co.jp/auth/o2/token'
|
|
84
|
+
"""
|
|
85
|
+
if region is None:
|
|
86
|
+
region = cls.DEFAULT_REGION
|
|
87
|
+
region = region.lower()
|
|
88
|
+
return cls.OAUTH_ENDPOINTS.get(region, cls.OAUTH_ENDPOINTS[cls.DEFAULT_REGION])
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def get_api_host(cls, region: Optional[str] = None) -> str:
|
|
92
|
+
"""Get API host (without protocol) for the specified region.
|
|
93
|
+
|
|
94
|
+
:param region: Region code (na, eu, fe) or None for default
|
|
95
|
+
:type region: Optional[str]
|
|
96
|
+
:return: API host without https://
|
|
97
|
+
:rtype: str
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
>>> RegionConfig.get_api_host("eu")
|
|
101
|
+
'advertising-api-eu.amazon.com'
|
|
102
|
+
"""
|
|
103
|
+
if region is None:
|
|
104
|
+
region = cls.DEFAULT_REGION
|
|
105
|
+
region = region.lower()
|
|
106
|
+
return cls.API_HOSTS.get(region, cls.API_HOSTS[cls.DEFAULT_REGION])
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def get_region_from_url(cls, url: str) -> str:
|
|
110
|
+
"""Extract region code from an API or OAuth URL.
|
|
111
|
+
|
|
112
|
+
:param url: URL to parse for region
|
|
113
|
+
:type url: str
|
|
114
|
+
:return: Region code (na, eu, fe)
|
|
115
|
+
:rtype: str
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
>>> RegionConfig.get_region_from_url("https://advertising-api-eu.amazon.com/v2/profiles")
|
|
119
|
+
'eu'
|
|
120
|
+
"""
|
|
121
|
+
if not url:
|
|
122
|
+
return cls.DEFAULT_REGION
|
|
123
|
+
|
|
124
|
+
# Parse URL and extract hostname only
|
|
125
|
+
parsed = urlparse(url)
|
|
126
|
+
hostname = (parsed.hostname or parsed.netloc or "").lower()
|
|
127
|
+
|
|
128
|
+
if not hostname:
|
|
129
|
+
return cls.DEFAULT_REGION
|
|
130
|
+
|
|
131
|
+
# Check for EU indicators in hostname only
|
|
132
|
+
if "-eu." in hostname or hostname.endswith(".co.uk") or "api-eu" in hostname:
|
|
133
|
+
return "eu"
|
|
134
|
+
|
|
135
|
+
# Check for FE indicators in hostname only
|
|
136
|
+
if "-fe." in hostname or hostname.endswith(".co.jp") or "api-fe" in hostname:
|
|
137
|
+
return "fe"
|
|
138
|
+
|
|
139
|
+
# Default to NA
|
|
140
|
+
return "na"
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def is_valid_region(cls, region: str) -> bool:
|
|
144
|
+
"""Check if a region code is valid.
|
|
145
|
+
|
|
146
|
+
:param region: Region code to validate
|
|
147
|
+
:type region: str
|
|
148
|
+
:return: True if valid, False otherwise
|
|
149
|
+
:rtype: bool
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
>>> RegionConfig.is_valid_region("eu")
|
|
153
|
+
True
|
|
154
|
+
>>> RegionConfig.is_valid_region("invalid")
|
|
155
|
+
False
|
|
156
|
+
"""
|
|
157
|
+
if not region:
|
|
158
|
+
return False
|
|
159
|
+
return region.lower() in cls.API_ENDPOINTS
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def get_region_name(cls, region: Optional[str] = None) -> str:
|
|
163
|
+
"""Get display name for a region.
|
|
164
|
+
|
|
165
|
+
:param region: Region code (na, eu, fe) or None for default
|
|
166
|
+
:type region: Optional[str]
|
|
167
|
+
:return: Human-readable region name
|
|
168
|
+
:rtype: str
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
>>> RegionConfig.get_region_name("na")
|
|
172
|
+
'North America'
|
|
173
|
+
"""
|
|
174
|
+
if region is None:
|
|
175
|
+
region = cls.DEFAULT_REGION
|
|
176
|
+
region = region.lower()
|
|
177
|
+
return cls.REGION_NAMES.get(region, cls.REGION_NAMES[cls.DEFAULT_REGION])
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def get_all_regions(cls) -> Dict[str, Dict[str, str]]:
|
|
181
|
+
"""Get all region configurations.
|
|
182
|
+
|
|
183
|
+
:return: Dictionary of all regions with their configurations
|
|
184
|
+
:rtype: Dict[str, Dict[str, str]]
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
>>> regions = RegionConfig.get_all_regions()
|
|
188
|
+
>>> regions["na"]["api_endpoint"]
|
|
189
|
+
'https://advertising-api.amazon.com'
|
|
190
|
+
"""
|
|
191
|
+
result = {}
|
|
192
|
+
for region in cls.API_ENDPOINTS.keys():
|
|
193
|
+
result[region] = {
|
|
194
|
+
"code": region,
|
|
195
|
+
"name": cls.get_region_name(region),
|
|
196
|
+
"api_endpoint": cls.get_api_endpoint(region),
|
|
197
|
+
"oauth_endpoint": cls.get_oauth_endpoint(region),
|
|
198
|
+
"api_host": cls.get_api_host(region),
|
|
199
|
+
}
|
|
200
|
+
return result
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Response wrapper to avoid manipulating private httpx attributes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResponseWrapper:
|
|
13
|
+
"""
|
|
14
|
+
Wrapper for HTTP responses that avoids accessing private attributes.
|
|
15
|
+
|
|
16
|
+
This wrapper provides a clean interface for response manipulation
|
|
17
|
+
without relying on httpx internals.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, response: httpx.Response):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the response wrapper.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
response: The original httpx response
|
|
26
|
+
"""
|
|
27
|
+
self.original_response = response
|
|
28
|
+
self._modified_content: Optional[bytes] = None
|
|
29
|
+
self._modified_json: Optional[Any] = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def status_code(self) -> int:
|
|
33
|
+
"""Get the status code."""
|
|
34
|
+
return self.original_response.status_code
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def headers(self) -> httpx.Headers:
|
|
38
|
+
"""Get the response headers."""
|
|
39
|
+
return self.original_response.headers
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def content(self) -> bytes:
|
|
43
|
+
"""Get the response content, either modified or original."""
|
|
44
|
+
if self._modified_content is not None:
|
|
45
|
+
return self._modified_content
|
|
46
|
+
return self.original_response.content
|
|
47
|
+
|
|
48
|
+
def json(self) -> Any:
|
|
49
|
+
"""Get the JSON content, either modified or original."""
|
|
50
|
+
if self._modified_json is not None:
|
|
51
|
+
return self._modified_json
|
|
52
|
+
if self._modified_content is not None:
|
|
53
|
+
return json.loads(self._modified_content)
|
|
54
|
+
return self.original_response.json()
|
|
55
|
+
|
|
56
|
+
def set_content(self, content: bytes):
|
|
57
|
+
"""
|
|
58
|
+
Set modified content.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
content: The new content bytes
|
|
62
|
+
"""
|
|
63
|
+
self._modified_content = content
|
|
64
|
+
self._modified_json = None # Clear JSON cache
|
|
65
|
+
|
|
66
|
+
def set_json(self, data: Any):
|
|
67
|
+
"""
|
|
68
|
+
Set modified JSON content.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
data: The new JSON data
|
|
72
|
+
"""
|
|
73
|
+
self._modified_json = data
|
|
74
|
+
self._modified_content = json.dumps(data).encode("utf-8")
|
|
75
|
+
|
|
76
|
+
def modify_json(self, modifier: callable) -> "ResponseWrapper":
|
|
77
|
+
"""
|
|
78
|
+
Modify JSON content with a function.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
modifier: Function that takes JSON data and returns modified data
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Self for chaining
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
current_json = self.json()
|
|
88
|
+
modified_json = modifier(current_json)
|
|
89
|
+
self.set_json(modified_json)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.warning(f"Failed to modify JSON response: {e}")
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def shape_amc_response(response: httpx.Response) -> httpx.Response:
|
|
96
|
+
"""
|
|
97
|
+
Shape AMC responses without accessing private attributes.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
response: The original response
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The response (potentially modified)
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
# Check if this is an AMC response that needs shaping
|
|
107
|
+
if response.status_code != 200:
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
content_type = response.headers.get("content-type", "")
|
|
111
|
+
if "application/json" not in content_type:
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
# Parse response
|
|
115
|
+
try:
|
|
116
|
+
data = response.json()
|
|
117
|
+
except Exception:
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
# Check if shaping is needed
|
|
121
|
+
modified = False
|
|
122
|
+
|
|
123
|
+
# Handle data wrapper
|
|
124
|
+
if isinstance(data, dict) and "data" in data:
|
|
125
|
+
# Unwrap single data element
|
|
126
|
+
if isinstance(data["data"], list) and len(data["data"]) == 1:
|
|
127
|
+
data = data["data"][0]
|
|
128
|
+
modified = True
|
|
129
|
+
|
|
130
|
+
# Handle ISO date conversion
|
|
131
|
+
if isinstance(data, dict):
|
|
132
|
+
for key, value in data.items():
|
|
133
|
+
if isinstance(value, str) and "T" in value and "Z" in value:
|
|
134
|
+
# Looks like ISO date, leave as-is (don't convert to epoch)
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
if modified:
|
|
138
|
+
# Create a new response with modified content
|
|
139
|
+
# Instead of modifying private _content attribute
|
|
140
|
+
content_bytes = json.dumps(data).encode("utf-8")
|
|
141
|
+
|
|
142
|
+
# Create new response object
|
|
143
|
+
new_response = httpx.Response(
|
|
144
|
+
status_code=response.status_code,
|
|
145
|
+
headers=dict(response.headers),
|
|
146
|
+
content=content_bytes,
|
|
147
|
+
request=response.request,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Update content-length header
|
|
151
|
+
new_response.headers["content-length"] = str(len(content_bytes))
|
|
152
|
+
|
|
153
|
+
return new_response
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.debug(f"AMC response shaping failed: {e}")
|
|
157
|
+
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def wrap_response(response: httpx.Response) -> ResponseWrapper:
|
|
162
|
+
"""
|
|
163
|
+
Wrap an httpx response for safe manipulation.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
response: The original response
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Wrapped response
|
|
170
|
+
"""
|
|
171
|
+
return ResponseWrapper(response)
|