airalo-sdk 1.0.1__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,186 @@
1
+ """
2
+ OAuth Service Module
3
+
4
+ This module handles OAuth authentication and access token management.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import time
10
+ from typing import Optional
11
+ from urllib.parse import urlencode
12
+
13
+ from ..config import Config
14
+ from ..constants.api_constants import ApiConstants
15
+ from ..constants.sdk_constants import SdkConstants
16
+ from ..exceptions.airalo_exception import AuthenticationError
17
+ from ..helpers.cached import Cached
18
+ from ..helpers.crypt import Crypt
19
+ from ..helpers.signature import Signature
20
+ from ..resources.http_resource import HttpResource
21
+
22
+
23
+ class OAuthService:
24
+ """
25
+ OAuth service for managing API authentication.
26
+
27
+ Handles access token generation, caching, and refresh with automatic retry logic.
28
+ """
29
+
30
+ CACHE_NAME = "airalo_access_token"
31
+ RETRY_LIMIT = SdkConstants.DEFAULT_RETRY_COUNT
32
+
33
+ def __init__(
34
+ self, config: Config, http_resource: HttpResource, signature: Signature
35
+ ):
36
+ """
37
+ Initialize OAuth service.
38
+
39
+ Args:
40
+ config: SDK configuration
41
+ http_resource: HTTP client for API requests
42
+ signature: Signature generator for request signing
43
+ """
44
+ self._config = config
45
+ self._http_resource = http_resource
46
+ self._signature = signature
47
+
48
+ # Prepare OAuth payload
49
+ self._payload = {
50
+ **self._config.get_credentials(),
51
+ "grant_type": "client_credentials",
52
+ }
53
+
54
+ def get_access_token(self) -> Optional[str]:
55
+ """
56
+ Get access token with caching and retry logic.
57
+
58
+ Returns:
59
+ Access token string or None if failed
60
+
61
+ Raises:
62
+ AuthenticationError: If token generation fails after retries
63
+ """
64
+ retry_count = 0
65
+
66
+ # Generate cache key based on credentials
67
+ cache_name = f"{self.CACHE_NAME}_{self._generate_cache_key()}"
68
+
69
+ while retry_count < self.RETRY_LIMIT:
70
+ try:
71
+ # Try to get cached token
72
+ encrypted_token = Cached.get(
73
+ lambda: self._request_token(),
74
+ cache_name,
75
+ ttl=SdkConstants.TOKEN_CACHE_TTL,
76
+ )
77
+
78
+ # Decrypt and return token
79
+ if encrypted_token:
80
+ return Crypt.decrypt(encrypted_token, self._get_encryption_key())
81
+
82
+ except Exception as e:
83
+ retry_count += 1
84
+
85
+ if retry_count >= self.RETRY_LIMIT:
86
+ raise AuthenticationError(
87
+ f"Failed to get access token from API after {self.RETRY_LIMIT} attempts: {str(e)}"
88
+ )
89
+
90
+ # Wait before retry (exponential backoff)
91
+ time.sleep(0.5 * (2 ** (retry_count - 1)))
92
+
93
+ return None
94
+
95
+ def _request_token(self) -> str:
96
+ """
97
+ Request new access token from API.
98
+
99
+ Returns:
100
+ Encrypted access token
101
+
102
+ Raises:
103
+ AuthenticationError: If token request fails
104
+ """
105
+ # Prepare request
106
+ url = self._config.get_url() + ApiConstants.TOKEN_SLUG
107
+
108
+ # Generate signature
109
+ signature_hash = self._signature.get_signature(self._payload)
110
+
111
+ # Set headers
112
+ headers = {
113
+ "Content-Type": "application/x-www-form-urlencoded",
114
+ "airalo-signature": signature_hash,
115
+ }
116
+
117
+ # Make request
118
+ self._http_resource.set_headers(headers)
119
+ response = self._http_resource.post(url, urlencode(self._payload))
120
+
121
+ # Check response
122
+ if not response or self._http_resource.code != 200:
123
+ raise AuthenticationError(
124
+ f"Access token generation failed, status code: {self._http_resource.code}, "
125
+ f"response: {response}"
126
+ )
127
+
128
+ # Parse response
129
+ try:
130
+ response_data = json.loads(response)
131
+ except json.JSONDecodeError as e:
132
+ raise AuthenticationError(
133
+ f"Failed to parse access token response: {str(e)}"
134
+ )
135
+
136
+ # Extract token
137
+ if not isinstance(response_data, dict) or "data" not in response_data:
138
+ raise AuthenticationError("Invalid response format: missing 'data' field")
139
+
140
+ if "access_token" not in response_data["data"]:
141
+ raise AuthenticationError("Access token not found in response")
142
+
143
+ access_token = response_data["data"]["access_token"]
144
+
145
+ # Encrypt token for caching
146
+ return Crypt.encrypt(access_token, self._get_encryption_key())
147
+
148
+ def _get_encryption_key(self) -> str:
149
+ """
150
+ Generate encryption key from credentials.
151
+
152
+ Returns:
153
+ Encryption key
154
+ """
155
+ credentials_string = self._config.get_credentials(as_string=True)
156
+ return hashlib.md5(credentials_string.encode()).hexdigest()
157
+
158
+ def _generate_cache_key(self) -> str:
159
+ """
160
+ Generate unique cache key for token storage.
161
+
162
+ Returns:
163
+ Cache key hash
164
+ """
165
+ credentials_string = self._config.get_credentials(as_string=True)
166
+ return hashlib.sha256(credentials_string.encode()).hexdigest()
167
+
168
+ def clear_token_cache(self) -> None:
169
+ """Clear cached access token."""
170
+ cache_name = f"{self.CACHE_NAME}_{self._generate_cache_key()}"
171
+ # Clear specific token cache
172
+ # Note: This would need enhancement in Cached class to clear specific cache
173
+ Cached.clear_cache()
174
+
175
+ def refresh_token(self) -> Optional[str]:
176
+ """
177
+ Force refresh of access token.
178
+
179
+ Returns:
180
+ New access token
181
+ """
182
+ # Clear existing cache
183
+ self.clear_token_cache()
184
+
185
+ # Get new token
186
+ return self.get_access_token()
@@ -0,0 +1,463 @@
1
+ """
2
+ Order Service Module
3
+
4
+ This module handles all order-related API operations including single orders,
5
+ bulk orders, and asynchronous orders.
6
+ """
7
+
8
+ import json
9
+ from typing import Any, Dict, List, Optional, Union
10
+ from urllib.parse import urlencode
11
+
12
+ from ..config import Config
13
+ from ..constants.api_constants import ApiConstants
14
+ from ..constants.sdk_constants import SdkConstants
15
+ from ..exceptions.airalo_exception import AiraloException, ValidationError, APIError
16
+ from ..helpers.signature import Signature
17
+ from ..helpers.cloud_sim_share_validator import CloudSimShareValidator
18
+ from ..resources.http_resource import HttpResource
19
+ from ..resources.multi_http_resource import MultiHttpResource
20
+
21
+
22
+ class OrderService:
23
+ """
24
+ Service for managing order operations.
25
+
26
+ Handles single orders, bulk orders, async orders, and email SIM sharing.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ config: Config,
32
+ http_resource: HttpResource,
33
+ multi_http_resource: MultiHttpResource,
34
+ signature: Signature,
35
+ access_token: str,
36
+ ):
37
+ """
38
+ Initialize order service.
39
+
40
+ Args:
41
+ config: SDK configuration
42
+ http_resource: HTTP client for single requests
43
+ multi_http_resource: HTTP client for concurrent requests
44
+ signature: Signature generator for request signing
45
+ access_token: API access token
46
+
47
+ Raises:
48
+ AiraloException: If access token is invalid
49
+ """
50
+ if not access_token:
51
+ raise AiraloException("Invalid access token, please check your credentials")
52
+
53
+ self._config = config
54
+ self._http = http_resource
55
+ self._multi_http = multi_http_resource
56
+ self._signature = signature
57
+ self._access_token = access_token
58
+ self._base_url = config.get_url()
59
+
60
+ def create_order(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
61
+ """
62
+ Create a single order.
63
+
64
+ Args:
65
+ payload: Order data including:
66
+ - package_id: Package ID to order
67
+ - quantity: Number of SIMs (1-50)
68
+ - type: Order type (default: 'sim')
69
+ - description: Order description
70
+
71
+ Returns:
72
+ Order response data or None
73
+
74
+ Raises:
75
+ ValidationError: If payload is invalid
76
+ APIError: If API request fails
77
+ """
78
+ self._validate_order(payload)
79
+
80
+ # Set default type if not provided
81
+ if "type" not in payload:
82
+ payload["type"] = "sim"
83
+
84
+ # Set headers with signature
85
+ headers = self._get_headers(payload)
86
+ self._http.set_headers(headers)
87
+
88
+ # Make request
89
+ url = self._base_url + ApiConstants.ORDERS_SLUG
90
+ response = self._http.post(url, payload)
91
+
92
+ # Check response
93
+ if self._http.code != 200:
94
+ raise APIError(
95
+ f"Order creation failed, status code: {self._http.code}, response: {response}"
96
+ )
97
+
98
+ # Parse and return response
99
+ try:
100
+ return json.loads(response)
101
+ except json.JSONDecodeError:
102
+ raise APIError("Failed to parse order response")
103
+
104
+ def create_order_with_email_sim_share(
105
+ self, payload: Dict[str, Any], esim_cloud: Dict[str, Any]
106
+ ) -> Optional[Dict[str, Any]]:
107
+ """
108
+ Create an order with email SIM sharing.
109
+
110
+ Args:
111
+ payload: Order data
112
+ esim_cloud: Email sharing configuration:
113
+ - to_email: Recipient email (required)
114
+ - sharing_option: List of options ['link', 'pdf'] (required)
115
+ - copy_address: List of CC emails (optional)
116
+
117
+ Returns:
118
+ Order response data or None
119
+ """
120
+ self._validate_order(payload)
121
+ self._validate_cloud_sim_share(esim_cloud)
122
+
123
+ # Add email sharing to payload
124
+ payload["to_email"] = esim_cloud["to_email"]
125
+ payload["sharing_option"] = esim_cloud["sharing_option"]
126
+
127
+ if esim_cloud.get("copy_address"):
128
+ payload["copy_address"] = esim_cloud["copy_address"]
129
+
130
+ # Set default type
131
+ if "type" not in payload:
132
+ payload["type"] = "sim"
133
+
134
+ # Make request
135
+ headers = self._get_headers(payload)
136
+ self._http.set_headers(headers)
137
+
138
+ url = self._base_url + ApiConstants.ORDERS_SLUG
139
+ response = self._http.post(url, payload)
140
+
141
+ if self._http.code != 200:
142
+ raise APIError(
143
+ f"Order creation failed, status code: {self._http.code}, response: {response}"
144
+ )
145
+
146
+ try:
147
+ return json.loads(response)
148
+ except json.JSONDecodeError:
149
+ raise APIError("Failed to parse order response")
150
+
151
+ def create_order_async(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
152
+ """
153
+ Create an asynchronous order.
154
+
155
+ Args:
156
+ payload: Order data including:
157
+ - package_id: Package ID to order
158
+ - quantity: Number of SIMs
159
+ - webhook_url: URL for order completion notification
160
+ - description: Order description
161
+
162
+ Returns:
163
+ Order response data or None
164
+ """
165
+ self._validate_order(payload)
166
+
167
+ # Set default type
168
+ if "type" not in payload:
169
+ payload["type"] = "sim"
170
+
171
+ # Make request
172
+ headers = self._get_headers(payload)
173
+ self._http.set_headers(headers)
174
+
175
+ url = self._base_url + ApiConstants.ASYNC_ORDERS_SLUG
176
+ response = self._http.post(url, payload)
177
+
178
+ # Async orders return 202 Accepted
179
+ if self._http.code != 202:
180
+ raise APIError(
181
+ f"Async order creation failed, status code: {self._http.code}, response: {response}"
182
+ )
183
+
184
+ try:
185
+ return json.loads(response)
186
+ except json.JSONDecodeError:
187
+ raise APIError("Failed to parse order response")
188
+
189
+ def create_order_bulk(
190
+ self,
191
+ packages: Union[Dict[str, int], List[Dict]],
192
+ description: Optional[str] = None,
193
+ ) -> Optional[Dict[str, Any]]:
194
+ """
195
+ Create multiple orders in bulk.
196
+
197
+ Args:
198
+ packages: Either:
199
+ - Dict mapping package_id to quantity
200
+ - List of dicts with 'package_id' and 'quantity' keys
201
+ description: Order description for all orders
202
+
203
+ Returns:
204
+ Dict mapping package IDs to order responses
205
+ """
206
+ # Convert list format to dict format
207
+ if isinstance(packages, list):
208
+ packages_dict = {}
209
+ for item in packages:
210
+ packages_dict[item["package_id"]] = item["quantity"]
211
+ packages = packages_dict
212
+
213
+ self._validate_bulk_order(packages)
214
+
215
+ if not packages:
216
+ return None
217
+
218
+ # Prepare concurrent requests
219
+ for package_id, quantity in packages.items():
220
+ payload = {
221
+ "package_id": package_id,
222
+ "quantity": quantity,
223
+ "type": "sim",
224
+ "description": description or "Bulk order placed via Airalo Python SDK",
225
+ }
226
+
227
+ self._validate_order(payload)
228
+
229
+ # Add request to multi-http queue
230
+ headers = self._get_headers(payload)
231
+ self._multi_http.tag(package_id).set_headers(headers).post(
232
+ self._base_url + ApiConstants.ORDERS_SLUG, payload
233
+ )
234
+
235
+ # Execute all requests
236
+ responses = self._multi_http.exec()
237
+
238
+ if not responses:
239
+ return None
240
+
241
+ # Parse responses
242
+ result = {}
243
+ for package_id, response in responses.items():
244
+ try:
245
+ result[package_id] = json.loads(response)
246
+ except json.JSONDecodeError:
247
+ result[package_id] = {
248
+ "error": "Failed to parse response",
249
+ "raw": response,
250
+ }
251
+
252
+ return result
253
+
254
+ def create_order_bulk_with_email_sim_share(
255
+ self,
256
+ packages: Union[Dict[str, int], List[Dict]],
257
+ esim_cloud: Dict[str, Any],
258
+ description: Optional[str] = None,
259
+ ) -> Optional[Dict[str, Any]]:
260
+ """
261
+ Create bulk orders with email SIM sharing.
262
+
263
+ Args:
264
+ packages: Package IDs and quantities
265
+ esim_cloud: Email sharing configuration
266
+ description: Order description
267
+
268
+ Returns:
269
+ Dict mapping package IDs to order responses
270
+ """
271
+ # Convert list format to dict format
272
+ if isinstance(packages, list):
273
+ packages_dict = {}
274
+ for item in packages:
275
+ packages_dict[item["package_id"]] = item["quantity"]
276
+ packages = packages_dict
277
+
278
+ self._validate_bulk_order(packages)
279
+ self._validate_cloud_sim_share(esim_cloud)
280
+
281
+ if not packages:
282
+ return None
283
+
284
+ # Prepare concurrent requests
285
+ for package_id, quantity in packages.items():
286
+ payload = {
287
+ "package_id": package_id,
288
+ "quantity": quantity,
289
+ "type": "sim",
290
+ "description": description or "Bulk order placed via Airalo Python SDK",
291
+ "to_email": esim_cloud["to_email"],
292
+ "sharing_option": esim_cloud["sharing_option"],
293
+ }
294
+
295
+ if esim_cloud.get("copy_address"):
296
+ payload["copy_address"] = esim_cloud["copy_address"]
297
+
298
+ self._validate_order(payload)
299
+
300
+ # Add request to queue
301
+ headers = self._get_headers(payload)
302
+ self._multi_http.tag(package_id).set_headers(headers).post(
303
+ self._base_url + ApiConstants.ORDERS_SLUG, payload
304
+ )
305
+
306
+ # Execute all requests
307
+ responses = self._multi_http.exec()
308
+
309
+ if not responses:
310
+ return None
311
+
312
+ # Parse responses
313
+ result = {}
314
+ for package_id, response in responses.items():
315
+ try:
316
+ result[package_id] = json.loads(response)
317
+ except json.JSONDecodeError:
318
+ result[package_id] = {
319
+ "error": "Failed to parse response",
320
+ "raw": response,
321
+ }
322
+
323
+ return result
324
+
325
+ def create_order_async_bulk(
326
+ self,
327
+ packages: Union[Dict[str, int], List[Dict]],
328
+ webhook_url: Optional[str] = None,
329
+ description: Optional[str] = None,
330
+ ) -> Optional[Dict[str, Any]]:
331
+ """
332
+ Create multiple asynchronous orders in bulk.
333
+
334
+ Args:
335
+ packages: Package IDs and quantities
336
+ webhook_url: Webhook URL for notifications
337
+ description: Order description
338
+
339
+ Returns:
340
+ Dict mapping package IDs to order responses
341
+ """
342
+ # Convert list format to dict format
343
+ if isinstance(packages, list):
344
+ packages_dict = {}
345
+ for item in packages:
346
+ packages_dict[item["package_id"]] = item["quantity"]
347
+ packages = packages_dict
348
+
349
+ self._validate_bulk_order(packages)
350
+
351
+ if not packages:
352
+ return None
353
+
354
+ # Prepare concurrent requests
355
+ for package_id, quantity in packages.items():
356
+ payload = {
357
+ "package_id": package_id,
358
+ "quantity": quantity,
359
+ "type": "sim",
360
+ "description": description
361
+ or "Bulk async order placed via Airalo Python SDK",
362
+ "webhook_url": webhook_url,
363
+ }
364
+
365
+ self._validate_order(payload)
366
+
367
+ # Add request to queue
368
+ headers = self._get_headers(payload)
369
+ self._multi_http.tag(package_id).set_headers(headers).post(
370
+ self._base_url + ApiConstants.ASYNC_ORDERS_SLUG, payload
371
+ )
372
+
373
+ # Execute all requests
374
+ responses = self._multi_http.exec()
375
+
376
+ if not responses:
377
+ return None
378
+
379
+ # Parse responses
380
+ result = {}
381
+ for package_id, response in responses.items():
382
+ try:
383
+ result[package_id] = json.loads(response)
384
+ except json.JSONDecodeError:
385
+ result[package_id] = {
386
+ "error": "Failed to parse response",
387
+ "raw": response,
388
+ }
389
+
390
+ return result
391
+
392
+ def _get_headers(self, payload: Dict[str, Any]) -> Dict[str, str]:
393
+ """
394
+ Get headers for order request with signature.
395
+
396
+ Args:
397
+ payload: Request payload
398
+
399
+ Returns:
400
+ Headers dictionary
401
+ """
402
+ return {
403
+ "Authorization": f"Bearer {self._access_token}",
404
+ "Content-Type": "application/json",
405
+ "airalo-signature": self._signature.get_signature(payload),
406
+ }
407
+
408
+ def _validate_order(self, payload: Dict[str, Any]) -> None:
409
+ """
410
+ Validate order payload.
411
+
412
+ Args:
413
+ payload: Order data
414
+
415
+ Raises:
416
+ ValidationError: If validation fails
417
+ """
418
+ if not payload.get("package_id"):
419
+ raise ValidationError(
420
+ f"The package_id is required, payload: {json.dumps(payload)}"
421
+ )
422
+
423
+ quantity = payload.get("quantity", 0)
424
+ if quantity < 1:
425
+ raise ValidationError(
426
+ f"The quantity must be at least 1, payload: {json.dumps(payload)}"
427
+ )
428
+
429
+ if quantity > SdkConstants.ORDER_LIMIT:
430
+ raise ValidationError(
431
+ f"The quantity may not be greater than {SdkConstants.ORDER_LIMIT}, "
432
+ f"payload: {json.dumps(payload)}"
433
+ )
434
+
435
+ def _validate_bulk_order(self, packages: Dict[str, int]) -> None:
436
+ """
437
+ Validate bulk order payload.
438
+
439
+ Args:
440
+ packages: Package IDs and quantities
441
+
442
+ Raises:
443
+ ValidationError: If validation fails
444
+ """
445
+ if len(packages) > SdkConstants.BULK_ORDER_LIMIT:
446
+ raise ValidationError(
447
+ f"The packages count may not be greater than {SdkConstants.BULK_ORDER_LIMIT}"
448
+ )
449
+
450
+ def _validate_cloud_sim_share(self, sim_cloud_share: Dict[str, Any]) -> None:
451
+ """
452
+ Validate email SIM sharing configuration using CloudSimShareValidator.
453
+
454
+ Args:
455
+ sim_cloud_share: Email sharing configuration
456
+
457
+ Raises:
458
+ ValidationError: If validation fails
459
+ """
460
+ # Use CloudSimShareValidator with required fields for order service
461
+ CloudSimShareValidator.validate(
462
+ sim_cloud_share, required_fields=["to_email", "sharing_option"]
463
+ )