asa-api-client 0.1.0__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.
@@ -0,0 +1,50 @@
1
+ """Apple Search Ads API client library.
2
+
3
+ A modern, fully-typed Python client for the Apple Search Ads API
4
+ with async support, Pydantic models, and comprehensive logging.
5
+
6
+ Example:
7
+ Basic usage with the client::
8
+
9
+ from asa_api_client import AppleSearchAdsClient
10
+
11
+ client = AppleSearchAdsClient(
12
+ client_id="your-client-id",
13
+ team_id="your-team-id",
14
+ key_id="your-key-id",
15
+ org_id=123456,
16
+ private_key_path="path/to/private-key.pem",
17
+ )
18
+
19
+ # List all campaigns
20
+ campaigns = client.campaigns.list()
21
+
22
+ # Async usage
23
+ campaigns = await client.campaigns.list_async()
24
+ """
25
+
26
+ from asa_api_client.client import AppleSearchAdsClient
27
+ from asa_api_client.exceptions import (
28
+ AppleSearchAdsError,
29
+ AuthenticationError,
30
+ ConfigurationError,
31
+ NotFoundError,
32
+ RateLimitError,
33
+ ValidationError,
34
+ )
35
+ from asa_api_client.logging import configure_logging
36
+ from asa_api_client.settings import Settings
37
+
38
+ __all__ = [
39
+ "AppleSearchAdsClient",
40
+ "AppleSearchAdsError",
41
+ "AuthenticationError",
42
+ "ConfigurationError",
43
+ "NotFoundError",
44
+ "RateLimitError",
45
+ "Settings",
46
+ "ValidationError",
47
+ "configure_logging",
48
+ ]
49
+
50
+ __version__ = "0.1.0"
asa_api_client/auth.py ADDED
@@ -0,0 +1,462 @@
1
+ """Authentication handling for the Apple Search Ads API.
2
+
3
+ This module handles OAuth 2.0 authentication with the Apple Search Ads API,
4
+ including JWT token generation and access token management.
5
+
6
+ The authentication flow:
7
+ 1. Generate a client secret (JWT) signed with your private key
8
+ 2. Exchange the client secret for an access token
9
+ 3. Use the access token for API requests
10
+ 4. Automatically refresh when the token expires
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import Self
17
+
18
+ import httpx
19
+ import jwt
20
+
21
+ from asa_api_client.exceptions import AuthenticationError, ConfigurationError, NetworkError
22
+ from asa_api_client.logging import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+ # Apple's OAuth token endpoint
27
+ TOKEN_URL = "https://appleid.apple.com/auth/oauth2/token"
28
+
29
+ # Apple Search Ads API audience
30
+ AUDIENCE = "https://appleid.apple.com"
31
+
32
+ # Scope for Search Ads API access
33
+ SCOPE = "searchadsorg"
34
+
35
+ # JWT algorithm
36
+ ALGORITHM = "ES256"
37
+
38
+ # Maximum lifetime for client secret (180 days per Apple's requirements)
39
+ MAX_CLIENT_SECRET_LIFETIME = timedelta(days=180)
40
+
41
+ # Default access token refresh buffer (refresh 5 minutes before expiry)
42
+ TOKEN_REFRESH_BUFFER = timedelta(minutes=5)
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class AccessToken:
47
+ """Represents an OAuth access token.
48
+
49
+ Attributes:
50
+ token: The access token string.
51
+ expires_at: When the token expires.
52
+ token_type: The type of token (typically "Bearer").
53
+ """
54
+
55
+ token: str
56
+ expires_at: datetime
57
+ token_type: str = "Bearer"
58
+
59
+ @property
60
+ def is_expired(self) -> bool:
61
+ """Check if the token is expired or about to expire.
62
+
63
+ Returns:
64
+ True if the token is expired or will expire within
65
+ the refresh buffer period.
66
+ """
67
+ return datetime.now(UTC) >= (self.expires_at - TOKEN_REFRESH_BUFFER)
68
+
69
+ @property
70
+ def authorization_header(self) -> str:
71
+ """Get the Authorization header value.
72
+
73
+ Returns:
74
+ The full Authorization header value including token type.
75
+ """
76
+ return f"{self.token_type} {self.token}"
77
+
78
+
79
+ class Authenticator:
80
+ """Handles authentication with the Apple Search Ads API.
81
+
82
+ This class manages the OAuth 2.0 flow for the Apple Search Ads API,
83
+ including JWT client secret generation and access token management.
84
+
85
+ Attributes:
86
+ client_id: Your Apple Search Ads API client ID.
87
+ team_id: Your Apple Developer team ID.
88
+ key_id: The key ID for your private key.
89
+ org_id: Your Apple Search Ads organization ID.
90
+
91
+ Example:
92
+ Create an authenticator and get an access token::
93
+
94
+ auth = Authenticator(
95
+ client_id="SEARCHADS.abc123",
96
+ team_id="TEAM123",
97
+ key_id="KEY123",
98
+ private_key_path=Path("private-key.pem"),
99
+ org_id=123456,
100
+ )
101
+
102
+ token = auth.get_access_token()
103
+ # Or async
104
+ token = await auth.get_access_token_async()
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ client_id: str,
111
+ team_id: str,
112
+ key_id: str,
113
+ org_id: int,
114
+ private_key: str | None = None,
115
+ private_key_path: Path | str | None = None,
116
+ ) -> None:
117
+ """Initialize the authenticator.
118
+
119
+ You must provide either `private_key` or `private_key_path`.
120
+
121
+ Args:
122
+ client_id: Your Apple Search Ads API client ID.
123
+ team_id: Your Apple Developer team ID.
124
+ key_id: The key ID for your private key.
125
+ org_id: Your Apple Search Ads organization ID.
126
+ private_key: The private key as a PEM-encoded string.
127
+ private_key_path: Path to the private key PEM file.
128
+
129
+ Raises:
130
+ ConfigurationError: If neither or both private key options
131
+ are provided, or if the key cannot be loaded.
132
+ """
133
+ self.client_id = client_id
134
+ self.team_id = team_id
135
+ self.key_id = key_id
136
+ self.org_id = org_id
137
+
138
+ self._private_key = self._load_private_key(private_key, private_key_path)
139
+ self._access_token: AccessToken | None = None
140
+ self._client_secret: str | None = None
141
+ self._client_secret_expires_at: datetime | None = None
142
+
143
+ logger.debug(
144
+ "Authenticator initialized for client_id=%s, org_id=%d",
145
+ self.client_id,
146
+ self.org_id,
147
+ )
148
+
149
+ def _load_private_key(
150
+ self,
151
+ private_key: str | None,
152
+ private_key_path: Path | str | None,
153
+ ) -> str:
154
+ """Load the private key from string or file.
155
+
156
+ Args:
157
+ private_key: The private key as a string.
158
+ private_key_path: Path to the private key file.
159
+
160
+ Returns:
161
+ The private key as a PEM-encoded string.
162
+
163
+ Raises:
164
+ ConfigurationError: If the key cannot be loaded.
165
+ """
166
+ if private_key and private_key_path:
167
+ raise ConfigurationError("Provide either 'private_key' or 'private_key_path', not both")
168
+
169
+ if not private_key and not private_key_path:
170
+ raise ConfigurationError("Must provide either 'private_key' or 'private_key_path'")
171
+
172
+ if private_key:
173
+ logger.debug("Using provided private key string")
174
+ return private_key
175
+
176
+ path = Path(private_key_path) if isinstance(private_key_path, str) else private_key_path
177
+ assert path is not None # For type checker
178
+
179
+ try:
180
+ key_content = path.read_text()
181
+ logger.debug("Loaded private key from %s", path)
182
+ return key_content
183
+ except FileNotFoundError as e:
184
+ raise ConfigurationError(f"Private key file not found: {path}") from e
185
+ except PermissionError as e:
186
+ raise ConfigurationError(f"Cannot read private key file: {path}") from e
187
+ except OSError as e:
188
+ raise ConfigurationError(f"Error reading private key file: {e}") from e
189
+
190
+ def _generate_client_secret(self) -> str:
191
+ """Generate a JWT client secret.
192
+
193
+ The client secret is a JWT signed with your private key that
194
+ Apple uses to verify your identity.
195
+
196
+ Returns:
197
+ The encoded JWT client secret.
198
+ """
199
+ now = datetime.now(UTC)
200
+ expiration = now + MAX_CLIENT_SECRET_LIFETIME
201
+
202
+ headers = {
203
+ "alg": ALGORITHM,
204
+ "kid": self.key_id,
205
+ }
206
+
207
+ payload = {
208
+ "sub": self.client_id,
209
+ "aud": AUDIENCE,
210
+ "iat": int(now.timestamp()),
211
+ "exp": int(expiration.timestamp()),
212
+ "iss": self.team_id,
213
+ }
214
+
215
+ try:
216
+ client_secret = jwt.encode(
217
+ payload,
218
+ self._private_key,
219
+ algorithm=ALGORITHM,
220
+ headers=headers,
221
+ )
222
+ except jwt.exceptions.PyJWTError as e:
223
+ raise AuthenticationError(f"Failed to generate client secret: {e}") from e
224
+
225
+ self._client_secret = client_secret
226
+ self._client_secret_expires_at = expiration
227
+
228
+ logger.debug("Generated new client secret, expires at %s", expiration)
229
+ return client_secret
230
+
231
+ @property
232
+ def client_secret(self) -> str:
233
+ """Get or generate the client secret.
234
+
235
+ Returns:
236
+ The JWT client secret, generating a new one if needed.
237
+ """
238
+ now = datetime.now(UTC)
239
+
240
+ if (
241
+ self._client_secret is None
242
+ or self._client_secret_expires_at is None
243
+ or now >= self._client_secret_expires_at - timedelta(days=1)
244
+ ):
245
+ return self._generate_client_secret()
246
+
247
+ return self._client_secret
248
+
249
+ def _request_access_token(self, http_client: httpx.Client) -> AccessToken:
250
+ """Request a new access token from Apple.
251
+
252
+ Args:
253
+ http_client: The HTTP client to use for the request.
254
+
255
+ Returns:
256
+ The new access token.
257
+
258
+ Raises:
259
+ AuthenticationError: If authentication fails.
260
+ NetworkError: If there's a network problem.
261
+ """
262
+ data = {
263
+ "grant_type": "client_credentials",
264
+ "client_id": self.client_id,
265
+ "client_secret": self.client_secret,
266
+ "scope": SCOPE,
267
+ }
268
+
269
+ logger.debug("Requesting access token from %s", TOKEN_URL)
270
+
271
+ try:
272
+ response = http_client.post(
273
+ TOKEN_URL,
274
+ data=data,
275
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
276
+ )
277
+ except httpx.RequestError as e:
278
+ raise NetworkError(f"Failed to request access token: {e}") from e
279
+
280
+ if response.status_code != 200:
281
+ error_data = response.json() if response.content else {}
282
+ error_msg = error_data.get(
283
+ "error_description", error_data.get("error", "Unknown error")
284
+ )
285
+ raise AuthenticationError(
286
+ f"Failed to obtain access token: {error_msg}",
287
+ status_code=response.status_code,
288
+ response_body=error_data,
289
+ )
290
+
291
+ token_data = response.json()
292
+ expires_in = token_data.get("expires_in", 3600)
293
+ expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
294
+
295
+ token = AccessToken(
296
+ token=token_data["access_token"],
297
+ expires_at=expires_at,
298
+ token_type=token_data.get("token_type", "Bearer"),
299
+ )
300
+
301
+ logger.info("Obtained new access token, expires at %s", expires_at)
302
+ return token
303
+
304
+ async def _request_access_token_async(self, http_client: httpx.AsyncClient) -> AccessToken:
305
+ """Request a new access token from Apple asynchronously.
306
+
307
+ Args:
308
+ http_client: The async HTTP client to use for the request.
309
+
310
+ Returns:
311
+ The new access token.
312
+
313
+ Raises:
314
+ AuthenticationError: If authentication fails.
315
+ NetworkError: If there's a network problem.
316
+ """
317
+ data = {
318
+ "grant_type": "client_credentials",
319
+ "client_id": self.client_id,
320
+ "client_secret": self.client_secret,
321
+ "scope": SCOPE,
322
+ }
323
+
324
+ logger.debug("Requesting access token from %s (async)", TOKEN_URL)
325
+
326
+ try:
327
+ response = await http_client.post(
328
+ TOKEN_URL,
329
+ data=data,
330
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
331
+ )
332
+ except httpx.RequestError as e:
333
+ raise NetworkError(f"Failed to request access token: {e}") from e
334
+
335
+ if response.status_code != 200:
336
+ error_data = response.json() if response.content else {}
337
+ error_msg = error_data.get(
338
+ "error_description", error_data.get("error", "Unknown error")
339
+ )
340
+ raise AuthenticationError(
341
+ f"Failed to obtain access token: {error_msg}",
342
+ status_code=response.status_code,
343
+ response_body=error_data,
344
+ )
345
+
346
+ token_data = response.json()
347
+ expires_in = token_data.get("expires_in", 3600)
348
+ expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
349
+
350
+ token = AccessToken(
351
+ token=token_data["access_token"],
352
+ expires_at=expires_at,
353
+ token_type=token_data.get("token_type", "Bearer"),
354
+ )
355
+
356
+ logger.info("Obtained new access token (async), expires at %s", expires_at)
357
+ return token
358
+
359
+ def get_access_token(self, http_client: httpx.Client) -> AccessToken:
360
+ """Get a valid access token, refreshing if necessary.
361
+
362
+ Args:
363
+ http_client: The HTTP client to use for token requests.
364
+
365
+ Returns:
366
+ A valid access token.
367
+ """
368
+ if self._access_token is None or self._access_token.is_expired:
369
+ self._access_token = self._request_access_token(http_client)
370
+
371
+ return self._access_token
372
+
373
+ async def get_access_token_async(self, http_client: httpx.AsyncClient) -> AccessToken:
374
+ """Get a valid access token asynchronously, refreshing if necessary.
375
+
376
+ Args:
377
+ http_client: The async HTTP client to use for token requests.
378
+
379
+ Returns:
380
+ A valid access token.
381
+ """
382
+ if self._access_token is None or self._access_token.is_expired:
383
+ self._access_token = await self._request_access_token_async(http_client)
384
+
385
+ return self._access_token
386
+
387
+ def invalidate_token(self) -> None:
388
+ """Invalidate the current access token.
389
+
390
+ Call this if you receive an authentication error to force
391
+ a new token to be obtained on the next request.
392
+ """
393
+ logger.debug("Invalidating current access token")
394
+ self._access_token = None
395
+
396
+ @classmethod
397
+ def from_env(cls, env_file: str | Path | None = ".env") -> Self:
398
+ """Create an Authenticator from environment variables and .env file.
399
+
400
+ Loads configuration from environment variables and optionally from
401
+ a `.env` file. Environment variables take precedence.
402
+
403
+ Required settings (via env vars or .env file):
404
+ - ASA_CLIENT_ID
405
+ - ASA_TEAM_ID
406
+ - ASA_KEY_ID
407
+ - ASA_ORG_ID
408
+ - ASA_PRIVATE_KEY or ASA_PRIVATE_KEY_PATH
409
+
410
+ Args:
411
+ env_file: Path to .env file to load. Set to None to skip
412
+ loading from file and only use environment variables.
413
+ Defaults to ".env".
414
+
415
+ Returns:
416
+ A configured Authenticator instance.
417
+
418
+ Raises:
419
+ ConfigurationError: If required settings are missing or invalid.
420
+
421
+ Example:
422
+ Load from environment and .env file::
423
+
424
+ auth = Authenticator.from_env()
425
+
426
+ Load from a specific env file::
427
+
428
+ auth = Authenticator.from_env(".env.production")
429
+
430
+ Only use environment variables (no file)::
431
+
432
+ auth = Authenticator.from_env(env_file=None)
433
+ """
434
+ from pydantic import ValidationError
435
+
436
+ from asa_api_client.settings import Settings
437
+
438
+ try:
439
+ if env_file is None:
440
+ # Only load from environment variables
441
+ settings = Settings(_env_file=None)
442
+ else:
443
+ settings = Settings(_env_file=env_file)
444
+ except ValidationError as e:
445
+ # Convert pydantic validation errors to ConfigurationError
446
+ errors = e.errors()
447
+ if errors:
448
+ first_error = errors[0]
449
+ field = first_error.get("loc", ["unknown"])[0]
450
+ msg = first_error.get("msg", "validation error")
451
+ raise ConfigurationError(
452
+ f"Configuration error for ASA_{str(field).upper()}: {msg}"
453
+ ) from e
454
+ raise ConfigurationError(f"Configuration error: {e}") from e
455
+
456
+ return cls(
457
+ client_id=settings.client_id,
458
+ team_id=settings.team_id,
459
+ key_id=settings.key_id,
460
+ org_id=settings.org_id,
461
+ private_key=settings.get_private_key(),
462
+ )