airalo-sdk 1.0.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.
Potentially problematic release.
This version of airalo-sdk might be problematic. Click here for more details.
- airalo/__init__.py +29 -0
- airalo/airalo.py +620 -0
- airalo/config.py +146 -0
- airalo/constants/__init__.py +8 -0
- airalo/constants/api_constants.py +49 -0
- airalo/constants/sdk_constants.py +32 -0
- airalo/exceptions/__init__.py +21 -0
- airalo/exceptions/airalo_exception.py +64 -0
- airalo/helpers/__init__.py +9 -0
- airalo/helpers/cached.py +177 -0
- airalo/helpers/cloud_sim_share_validator.py +89 -0
- airalo/helpers/crypt.py +154 -0
- airalo/helpers/date_helper.py +12 -0
- airalo/helpers/signature.py +119 -0
- airalo/resources/__init__.py +8 -0
- airalo/resources/http_resource.py +324 -0
- airalo/resources/multi_http_resource.py +312 -0
- airalo/services/__init__.py +17 -0
- airalo/services/compatibility_devices_service.py +34 -0
- airalo/services/exchange_rates_service.py +69 -0
- airalo/services/future_order_service.py +113 -0
- airalo/services/installation_instructions_service.py +63 -0
- airalo/services/oauth_service.py +186 -0
- airalo/services/order_service.py +463 -0
- airalo/services/packages_service.py +354 -0
- airalo/services/sim_service.py +349 -0
- airalo/services/topup_service.py +127 -0
- airalo/services/voucher_service.py +138 -0
- airalo_sdk-1.0.0.dist-info/METADATA +939 -0
- airalo_sdk-1.0.0.dist-info/RECORD +33 -0
- airalo_sdk-1.0.0.dist-info/WHEEL +5 -0
- airalo_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- airalo_sdk-1.0.0.dist-info/top_level.txt +1 -0
airalo/helpers/crypt.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Encryption Helper Module
|
|
3
|
+
|
|
4
|
+
This module handles encryption and decryption operations using the cryptography library.
|
|
5
|
+
Provides equivalent functionality to PHP's sodium library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, Union
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
13
|
+
|
|
14
|
+
from ..exceptions.airalo_exception import AiraloException
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Crypt:
|
|
18
|
+
"""
|
|
19
|
+
Encryption/decryption utility class.
|
|
20
|
+
|
|
21
|
+
Uses ChaCha20-Poly1305 for authenticated encryption, which is equivalent
|
|
22
|
+
to sodium's crypto_secretbox functionality.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Constants matching sodium's requirements
|
|
26
|
+
KEY_BYTES = 32 # SODIUM_CRYPTO_SECRETBOX_KEYBYTES
|
|
27
|
+
NONCE_BYTES = 12 # For ChaCha20-Poly1305
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def encrypt(data: str, key: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Encrypt data using ChaCha20-Poly1305.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
data: Plain text data to encrypt
|
|
36
|
+
key: Encryption key (will be truncated/padded to 32 bytes)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Base64-encoded encrypted data with nonce prepended
|
|
40
|
+
"""
|
|
41
|
+
if not data or not key:
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
# Prepare key (ensure it's exactly 32 bytes)
|
|
45
|
+
key_bytes = Crypt._prepare_key(key)
|
|
46
|
+
|
|
47
|
+
# Check if data is already encrypted
|
|
48
|
+
if Crypt.is_encrypted(data):
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
# Generate random nonce
|
|
52
|
+
nonce = os.urandom(Crypt.NONCE_BYTES)
|
|
53
|
+
|
|
54
|
+
# Create cipher and encrypt
|
|
55
|
+
cipher = ChaCha20Poly1305(key_bytes)
|
|
56
|
+
encrypted = cipher.encrypt(nonce, data.encode("utf-8"), None)
|
|
57
|
+
|
|
58
|
+
# Combine nonce and encrypted data, then base64 encode
|
|
59
|
+
combined = nonce + encrypted
|
|
60
|
+
return base64.b64encode(combined).decode("ascii")
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def decrypt(data: str, key: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Decrypt data encrypted with ChaCha20-Poly1305.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
data: Base64-encoded encrypted data
|
|
69
|
+
key: Decryption key
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Decrypted plain text
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
AiraloException: If decryption fails
|
|
76
|
+
"""
|
|
77
|
+
if not data or not key:
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
# Prepare key
|
|
81
|
+
key_bytes = Crypt._prepare_key(key)
|
|
82
|
+
|
|
83
|
+
# Check if data is encrypted
|
|
84
|
+
if not Crypt.is_encrypted(data):
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Decode from base64
|
|
89
|
+
encrypted = base64.b64decode(data)
|
|
90
|
+
|
|
91
|
+
# Extract nonce and ciphertext
|
|
92
|
+
nonce = encrypted[: Crypt.NONCE_BYTES]
|
|
93
|
+
ciphertext = encrypted[Crypt.NONCE_BYTES :]
|
|
94
|
+
|
|
95
|
+
# Decrypt
|
|
96
|
+
cipher = ChaCha20Poly1305(key_bytes)
|
|
97
|
+
decrypted = cipher.decrypt(nonce, ciphertext, None)
|
|
98
|
+
|
|
99
|
+
return decrypted.decode("utf-8")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise AiraloException(f"Decryption failed: {e}")
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def is_encrypted(data: Any) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Check if data appears to be encrypted.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
data: Data to check
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if data appears to be encrypted, False otherwise
|
|
113
|
+
"""
|
|
114
|
+
# Check for non-string types
|
|
115
|
+
if not isinstance(data, str):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# Check minimum length (nonce + some ciphertext + base64 overhead)
|
|
119
|
+
if len(data) < 56:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
# Check if it's numeric (encrypted data shouldn't be purely numeric)
|
|
123
|
+
if data.isdigit():
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
# Check if it's valid base64
|
|
127
|
+
try:
|
|
128
|
+
decoded = base64.b64decode(data, validate=True)
|
|
129
|
+
# Verify we can encode it back to the same string
|
|
130
|
+
return base64.b64encode(decoded).decode("ascii") == data
|
|
131
|
+
except Exception:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _prepare_key(key: str) -> bytes:
|
|
136
|
+
"""
|
|
137
|
+
Prepare encryption key to be exactly 32 bytes.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
key: Raw key string
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
32-byte key
|
|
144
|
+
"""
|
|
145
|
+
key_bytes = key.encode("utf-8")
|
|
146
|
+
|
|
147
|
+
# Truncate or pad to exactly 32 bytes
|
|
148
|
+
if len(key_bytes) > Crypt.KEY_BYTES:
|
|
149
|
+
return key_bytes[: Crypt.KEY_BYTES]
|
|
150
|
+
elif len(key_bytes) < Crypt.KEY_BYTES:
|
|
151
|
+
# Pad with zeros
|
|
152
|
+
return key_bytes.ljust(Crypt.KEY_BYTES, b"\0")
|
|
153
|
+
|
|
154
|
+
return key_bytes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# date_helper.py
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DateHelper:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def validate_date(date_str: str, date_format: str = "%Y-%m-%d") -> bool:
|
|
8
|
+
try:
|
|
9
|
+
datetime.strptime(date_str, date_format)
|
|
10
|
+
return True
|
|
11
|
+
except ValueError:
|
|
12
|
+
return False
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signature Helper Module
|
|
3
|
+
|
|
4
|
+
This module handles HMAC signature generation for API authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Optional, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Signature:
|
|
14
|
+
"""
|
|
15
|
+
Signature generator for API authentication.
|
|
16
|
+
|
|
17
|
+
Generates HMAC-SHA512 signatures for request payload validation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, secret: str):
|
|
21
|
+
"""
|
|
22
|
+
Initialize signature generator.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
secret: Secret key for HMAC generation
|
|
26
|
+
"""
|
|
27
|
+
self._secret = secret
|
|
28
|
+
|
|
29
|
+
def get_signature(self, payload: Any) -> Optional[str]:
|
|
30
|
+
"""
|
|
31
|
+
Generate HMAC signature for payload.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
payload: Request payload (dict, string, or any JSON-serializable object)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
HMAC-SHA512 signature as hex string, or None if payload is empty
|
|
38
|
+
"""
|
|
39
|
+
prepared_payload = self._prepare_payload(payload)
|
|
40
|
+
if not prepared_payload:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
return self._sign_data(prepared_payload)
|
|
44
|
+
|
|
45
|
+
def check_signature(self, hash_value: Optional[str], payload: Any) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Verify HMAC signature.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
hash_value: Expected signature
|
|
51
|
+
payload: Request payload
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
True if signature matches, False otherwise
|
|
55
|
+
"""
|
|
56
|
+
if not hash_value:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
prepared_payload = self._prepare_payload(payload)
|
|
60
|
+
if not prepared_payload:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
expected_signature = self._sign_data(prepared_payload)
|
|
64
|
+
return hmac.compare_digest(expected_signature, hash_value)
|
|
65
|
+
|
|
66
|
+
def _prepare_payload(self, payload: Any) -> Optional[str]:
|
|
67
|
+
"""
|
|
68
|
+
Prepare payload for signing.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
payload: Raw payload
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
JSON string representation of payload, or None if empty
|
|
75
|
+
"""
|
|
76
|
+
if not payload:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
if isinstance(payload, str):
|
|
80
|
+
# If it's already a string, ensure it's valid JSON by parsing and re-encoding
|
|
81
|
+
try:
|
|
82
|
+
payload = self._escape_forward_slashes(payload)
|
|
83
|
+
|
|
84
|
+
# Remove whitespaces by parsing and re-encoding
|
|
85
|
+
parsed = json.loads(payload)
|
|
86
|
+
return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
# If not valid JSON, return as is
|
|
89
|
+
return payload
|
|
90
|
+
|
|
91
|
+
# Convert to JSON string
|
|
92
|
+
try:
|
|
93
|
+
json_encoded = json.dumps(
|
|
94
|
+
payload, separators=(",", ":"), ensure_ascii=False
|
|
95
|
+
)
|
|
96
|
+
return self._escape_forward_slashes(json_encoded)
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
# If not JSON-serializable, convert to string
|
|
99
|
+
return str(payload)
|
|
100
|
+
|
|
101
|
+
def _escape_forward_slashes(self, payload: str) -> str:
|
|
102
|
+
return payload.replace("/", "\\/")
|
|
103
|
+
|
|
104
|
+
def _sign_data(self, payload: str, algo: str = "sha512") -> str:
|
|
105
|
+
"""
|
|
106
|
+
Generate HMAC signature.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
payload: Prepared payload string
|
|
110
|
+
algo: Hash algorithm (default: sha512)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
HMAC signature as hex string
|
|
114
|
+
"""
|
|
115
|
+
return hmac.new(
|
|
116
|
+
self._secret.encode("utf-8"),
|
|
117
|
+
payload.encode("utf-8"),
|
|
118
|
+
getattr(hashlib, algo),
|
|
119
|
+
).hexdigest()
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Resource Module
|
|
3
|
+
|
|
4
|
+
This module provides HTTP client functionality using urllib for making API requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import ssl
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
13
|
+
from urllib.parse import urlencode
|
|
14
|
+
|
|
15
|
+
from ..config import Config
|
|
16
|
+
from ..constants.sdk_constants import SdkConstants
|
|
17
|
+
from ..exceptions.airalo_exception import NetworkError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpResource:
|
|
21
|
+
"""
|
|
22
|
+
HTTP client for making API requests.
|
|
23
|
+
|
|
24
|
+
Uses urllib for HTTP operations with support for GET, POST, and HEAD methods.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Config, get_handler: bool = False):
|
|
28
|
+
"""
|
|
29
|
+
Initialize HTTP resource.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config: SDK configuration
|
|
33
|
+
get_handler: If True, return request object instead of executing
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
NetworkError: If urllib is not available
|
|
37
|
+
"""
|
|
38
|
+
self._config = config
|
|
39
|
+
self._get_handler = get_handler
|
|
40
|
+
self._ignore_ssl = False
|
|
41
|
+
self._timeout = SdkConstants.DEFAULT_TIMEOUT
|
|
42
|
+
self._rfc = 1 # Default to RFC1738 for query encoding
|
|
43
|
+
|
|
44
|
+
# Response attributes
|
|
45
|
+
self.header: str = ""
|
|
46
|
+
self.code: int = 0
|
|
47
|
+
self.response_headers: Dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
# Request headers
|
|
50
|
+
self._request_headers: Dict[str, str] = {}
|
|
51
|
+
self._default_headers: Dict[str, str] = {
|
|
52
|
+
"User-Agent": f"Airalo-Python-SDK/{SdkConstants.VERSION}",
|
|
53
|
+
"airalo-python-sdk": f"{SdkConstants.VERSION}",
|
|
54
|
+
"Accept": "application/json",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Initialize headers
|
|
58
|
+
self._init_headers()
|
|
59
|
+
|
|
60
|
+
def get(
|
|
61
|
+
self, url: str, params: Optional[Dict[str, Any]] = None
|
|
62
|
+
) -> Union[str, urllib.request.Request]:
|
|
63
|
+
"""
|
|
64
|
+
Perform GET request.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
url: Request URL
|
|
68
|
+
params: Query parameters
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Response body as string or request object if get_handler is True
|
|
72
|
+
"""
|
|
73
|
+
if params:
|
|
74
|
+
# Build query string
|
|
75
|
+
query_string = self._build_query_string(params)
|
|
76
|
+
url = f"{url.rstrip('?')}?{query_string}"
|
|
77
|
+
|
|
78
|
+
return self._request(url, method="GET")
|
|
79
|
+
|
|
80
|
+
def post(
|
|
81
|
+
self, url: str, params: Optional[Union[Dict[str, Any], str]] = None
|
|
82
|
+
) -> Union[str, urllib.request.Request]:
|
|
83
|
+
"""
|
|
84
|
+
Perform POST request.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
url: Request URL
|
|
88
|
+
params: Request body (dict or string)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Response body as string or request object if get_handler is True
|
|
92
|
+
"""
|
|
93
|
+
data = None
|
|
94
|
+
|
|
95
|
+
if params:
|
|
96
|
+
if isinstance(params, dict):
|
|
97
|
+
# Check if we should send as JSON or form data
|
|
98
|
+
if (
|
|
99
|
+
"Content-Type" in self._request_headers
|
|
100
|
+
and "json" in self._request_headers["Content-Type"]
|
|
101
|
+
):
|
|
102
|
+
data = json.dumps(params).encode("utf-8")
|
|
103
|
+
else:
|
|
104
|
+
data = urlencode(params).encode("utf-8")
|
|
105
|
+
else:
|
|
106
|
+
# Assume it's already a string (could be JSON or form-encoded)
|
|
107
|
+
data = params.encode("utf-8") if isinstance(params, str) else params
|
|
108
|
+
|
|
109
|
+
return self._request(url, data=data, method="POST")
|
|
110
|
+
|
|
111
|
+
def head(
|
|
112
|
+
self, url: str, params: Optional[Dict[str, Any]] = None
|
|
113
|
+
) -> Union[str, urllib.request.Request]:
|
|
114
|
+
"""
|
|
115
|
+
Perform HEAD request.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
url: Request URL
|
|
119
|
+
params: Query parameters
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Response headers as string or request object if get_handler is True
|
|
123
|
+
"""
|
|
124
|
+
if params:
|
|
125
|
+
query_string = self._build_query_string(params)
|
|
126
|
+
url = f"{url.rstrip('?')}?{query_string}"
|
|
127
|
+
|
|
128
|
+
return self._request(url, method="HEAD")
|
|
129
|
+
|
|
130
|
+
def set_headers(self, headers: Union[Dict[str, str], List[str]]) -> "HttpResource":
|
|
131
|
+
"""
|
|
132
|
+
Set additional request headers.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
headers: Dictionary or list of header strings
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Self for method chaining
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(headers, list):
|
|
141
|
+
# Parse list of header strings
|
|
142
|
+
for header in headers:
|
|
143
|
+
if ":" in header:
|
|
144
|
+
key, value = header.split(":", 1)
|
|
145
|
+
self._request_headers[key.strip()] = value.strip()
|
|
146
|
+
else:
|
|
147
|
+
# Merge dictionary
|
|
148
|
+
self._request_headers.update(headers)
|
|
149
|
+
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def set_timeout(self, timeout: int = 30) -> "HttpResource":
|
|
153
|
+
"""
|
|
154
|
+
Set request timeout.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
timeout: Timeout in seconds
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Self for method chaining
|
|
161
|
+
"""
|
|
162
|
+
self._timeout = timeout
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def ignore_ssl(self) -> "HttpResource":
|
|
166
|
+
"""
|
|
167
|
+
Ignore SSL certificate verification.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Self for method chaining
|
|
171
|
+
"""
|
|
172
|
+
self._ignore_ssl = True
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def use_rfc(self, rfc: int) -> "HttpResource":
|
|
176
|
+
"""
|
|
177
|
+
Set RFC standard for URL encoding.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
rfc: RFC standard (1 for RFC1738, 3 for RFC3986)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Self for method chaining
|
|
184
|
+
"""
|
|
185
|
+
self._rfc = rfc
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def set_basic_authentication(
|
|
189
|
+
self, username: str, password: str = ""
|
|
190
|
+
) -> "HttpResource":
|
|
191
|
+
"""
|
|
192
|
+
Set HTTP basic authentication.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
username: Username
|
|
196
|
+
password: Password
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Self for method chaining
|
|
200
|
+
"""
|
|
201
|
+
import base64
|
|
202
|
+
|
|
203
|
+
credentials = base64.b64encode(f"{username}:{password}".encode()).decode(
|
|
204
|
+
"ascii"
|
|
205
|
+
)
|
|
206
|
+
self._request_headers["Authorization"] = f"Basic {credentials}"
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
def _request(
|
|
210
|
+
self, url: str, data: Optional[bytes] = None, method: str = "GET"
|
|
211
|
+
) -> Union[str, urllib.request.Request]:
|
|
212
|
+
"""
|
|
213
|
+
Execute HTTP request.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
url: Request URL
|
|
217
|
+
data: Request body
|
|
218
|
+
method: HTTP method
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Response body or request object
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
NetworkError: If request fails
|
|
225
|
+
"""
|
|
226
|
+
# Create request object
|
|
227
|
+
request = urllib.request.Request(url, data=data, method=method)
|
|
228
|
+
|
|
229
|
+
# Add headers
|
|
230
|
+
for key, value in self._request_headers.items():
|
|
231
|
+
request.add_header(key, value)
|
|
232
|
+
|
|
233
|
+
# Return request object if handler mode
|
|
234
|
+
if self._get_handler:
|
|
235
|
+
return request
|
|
236
|
+
|
|
237
|
+
# Configure SSL context
|
|
238
|
+
context = None
|
|
239
|
+
if self._ignore_ssl:
|
|
240
|
+
context = ssl.create_default_context()
|
|
241
|
+
context.check_hostname = False
|
|
242
|
+
context.verify_mode = ssl.CERT_NONE
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
# Execute request
|
|
246
|
+
response = urllib.request.urlopen(
|
|
247
|
+
request, timeout=self._timeout, context=context
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Read response
|
|
251
|
+
response_body = response.read().decode("utf-8")
|
|
252
|
+
|
|
253
|
+
# Store response details
|
|
254
|
+
self.code = response.getcode()
|
|
255
|
+
self.response_headers = dict(response.headers)
|
|
256
|
+
self.header = str(response.headers)
|
|
257
|
+
|
|
258
|
+
# Reset for next request
|
|
259
|
+
self._reset()
|
|
260
|
+
|
|
261
|
+
return response_body
|
|
262
|
+
|
|
263
|
+
except urllib.error.HTTPError as e:
|
|
264
|
+
# HTTP error (4xx, 5xx)
|
|
265
|
+
self.code = e.code
|
|
266
|
+
self.response_headers = dict(e.headers)
|
|
267
|
+
self.header = str(e.headers)
|
|
268
|
+
|
|
269
|
+
# Try to read error response
|
|
270
|
+
try:
|
|
271
|
+
error_body = e.read().decode("utf-8")
|
|
272
|
+
self._reset()
|
|
273
|
+
return error_body
|
|
274
|
+
except:
|
|
275
|
+
self._reset()
|
|
276
|
+
raise NetworkError(f"HTTP {e.code}: {e.reason}", http_status=e.code)
|
|
277
|
+
|
|
278
|
+
except urllib.error.URLError as e:
|
|
279
|
+
# Network error
|
|
280
|
+
self._reset()
|
|
281
|
+
raise NetworkError(f"Network error: {e.reason}")
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
# Other errors
|
|
285
|
+
self._reset()
|
|
286
|
+
raise NetworkError(f"Request failed: {str(e)}")
|
|
287
|
+
|
|
288
|
+
def _init_headers(self) -> None:
|
|
289
|
+
"""Initialize request headers with defaults and config headers."""
|
|
290
|
+
self._request_headers = self._default_headers.copy()
|
|
291
|
+
|
|
292
|
+
# Add custom headers from config
|
|
293
|
+
config_headers = self._config.get_http_headers()
|
|
294
|
+
if isinstance(config_headers, list):
|
|
295
|
+
for header in config_headers:
|
|
296
|
+
if ":" in header:
|
|
297
|
+
key, value = header.split(":", 1)
|
|
298
|
+
self._request_headers[key.strip()] = value.strip()
|
|
299
|
+
elif isinstance(config_headers, dict):
|
|
300
|
+
self._request_headers.update(config_headers)
|
|
301
|
+
|
|
302
|
+
def _reset(self) -> None:
|
|
303
|
+
"""Reset headers and state for next request."""
|
|
304
|
+
self._init_headers()
|
|
305
|
+
self._ignore_ssl = False
|
|
306
|
+
self._timeout = SdkConstants.DEFAULT_TIMEOUT
|
|
307
|
+
|
|
308
|
+
def _build_query_string(self, params: Dict[str, Any]) -> str:
|
|
309
|
+
"""
|
|
310
|
+
Build query string from parameters.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
params: Query parameters
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
URL-encoded query string
|
|
317
|
+
"""
|
|
318
|
+
# Use appropriate encoding based on RFC setting
|
|
319
|
+
if self._rfc == 3:
|
|
320
|
+
# RFC3986 - uses %20 for spaces
|
|
321
|
+
return urlencode(params, quote_via=urllib.parse.quote)
|
|
322
|
+
else:
|
|
323
|
+
# RFC1738 - uses + for spaces (default)
|
|
324
|
+
return urlencode(params)
|