binalyze-air-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.
- binalyze_air/__init__.py +77 -0
- binalyze_air/apis/__init__.py +27 -0
- binalyze_air/apis/authentication.py +27 -0
- binalyze_air/apis/auto_asset_tags.py +75 -0
- binalyze_air/apis/endpoints.py +22 -0
- binalyze_air/apis/event_subscription.py +97 -0
- binalyze_air/apis/evidence.py +53 -0
- binalyze_air/apis/evidences.py +216 -0
- binalyze_air/apis/interact.py +36 -0
- binalyze_air/apis/params.py +40 -0
- binalyze_air/apis/settings.py +27 -0
- binalyze_air/apis/user_management.py +74 -0
- binalyze_air/apis/users.py +68 -0
- binalyze_air/apis/webhooks.py +231 -0
- binalyze_air/base.py +133 -0
- binalyze_air/client.py +1338 -0
- binalyze_air/commands/__init__.py +146 -0
- binalyze_air/commands/acquisitions.py +387 -0
- binalyze_air/commands/assets.py +363 -0
- binalyze_air/commands/authentication.py +37 -0
- binalyze_air/commands/auto_asset_tags.py +231 -0
- binalyze_air/commands/baseline.py +396 -0
- binalyze_air/commands/cases.py +603 -0
- binalyze_air/commands/event_subscription.py +102 -0
- binalyze_air/commands/evidences.py +988 -0
- binalyze_air/commands/interact.py +58 -0
- binalyze_air/commands/organizations.py +221 -0
- binalyze_air/commands/policies.py +203 -0
- binalyze_air/commands/settings.py +29 -0
- binalyze_air/commands/tasks.py +56 -0
- binalyze_air/commands/triage.py +360 -0
- binalyze_air/commands/user_management.py +126 -0
- binalyze_air/commands/users.py +101 -0
- binalyze_air/config.py +245 -0
- binalyze_air/exceptions.py +50 -0
- binalyze_air/http_client.py +306 -0
- binalyze_air/models/__init__.py +285 -0
- binalyze_air/models/acquisitions.py +251 -0
- binalyze_air/models/assets.py +439 -0
- binalyze_air/models/audit.py +273 -0
- binalyze_air/models/authentication.py +70 -0
- binalyze_air/models/auto_asset_tags.py +117 -0
- binalyze_air/models/baseline.py +232 -0
- binalyze_air/models/cases.py +276 -0
- binalyze_air/models/endpoints.py +76 -0
- binalyze_air/models/event_subscription.py +172 -0
- binalyze_air/models/evidence.py +66 -0
- binalyze_air/models/evidences.py +349 -0
- binalyze_air/models/interact.py +136 -0
- binalyze_air/models/organizations.py +294 -0
- binalyze_air/models/params.py +128 -0
- binalyze_air/models/policies.py +250 -0
- binalyze_air/models/settings.py +84 -0
- binalyze_air/models/tasks.py +149 -0
- binalyze_air/models/triage.py +143 -0
- binalyze_air/models/user_management.py +97 -0
- binalyze_air/models/users.py +82 -0
- binalyze_air/queries/__init__.py +134 -0
- binalyze_air/queries/acquisitions.py +156 -0
- binalyze_air/queries/assets.py +105 -0
- binalyze_air/queries/audit.py +417 -0
- binalyze_air/queries/authentication.py +56 -0
- binalyze_air/queries/auto_asset_tags.py +60 -0
- binalyze_air/queries/baseline.py +185 -0
- binalyze_air/queries/cases.py +293 -0
- binalyze_air/queries/endpoints.py +25 -0
- binalyze_air/queries/event_subscription.py +55 -0
- binalyze_air/queries/evidence.py +140 -0
- binalyze_air/queries/evidences.py +280 -0
- binalyze_air/queries/interact.py +28 -0
- binalyze_air/queries/organizations.py +223 -0
- binalyze_air/queries/params.py +115 -0
- binalyze_air/queries/policies.py +150 -0
- binalyze_air/queries/settings.py +20 -0
- binalyze_air/queries/tasks.py +82 -0
- binalyze_air/queries/triage.py +231 -0
- binalyze_air/queries/user_management.py +83 -0
- binalyze_air/queries/users.py +69 -0
- binalyze_air_sdk-1.0.1.dist-info/METADATA +635 -0
- binalyze_air_sdk-1.0.1.dist-info/RECORD +82 -0
- binalyze_air_sdk-1.0.1.dist-info/WHEEL +5 -0
- binalyze_air_sdk-1.0.1.dist-info/top_level.txt +1 -0
binalyze_air/config.py
ADDED
@@ -0,0 +1,245 @@
|
|
1
|
+
"""
|
2
|
+
Configuration management for the Binalyze AIR SDK.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import json
|
7
|
+
from typing import Optional, Dict, Any, List
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
9
|
+
|
10
|
+
|
11
|
+
class PaginationConfig(BaseModel):
|
12
|
+
"""Pagination configuration."""
|
13
|
+
default_page_size: int = Field(default=10, description="Default page size")
|
14
|
+
max_page_size: int = Field(default=100, description="Maximum page size")
|
15
|
+
default_sort_by: str = Field(default="createdAt", description="Default sort field")
|
16
|
+
default_sort_type: str = Field(default="ASC", description="Default sort type")
|
17
|
+
|
18
|
+
|
19
|
+
class EndpointConfig(BaseModel):
|
20
|
+
"""API endpoint configuration."""
|
21
|
+
acquisitions: Dict[str, str] = Field(default_factory=dict)
|
22
|
+
assets: Dict[str, str] = Field(default_factory=dict)
|
23
|
+
audit: Dict[str, str] = Field(default_factory=dict)
|
24
|
+
auth: Dict[str, str] = Field(default_factory=dict)
|
25
|
+
auto_asset_tags: Dict[str, str] = Field(default_factory=dict)
|
26
|
+
baseline: Dict[str, str] = Field(default_factory=dict)
|
27
|
+
cases: Dict[str, str] = Field(default_factory=dict)
|
28
|
+
policies: Dict[str, str] = Field(default_factory=dict)
|
29
|
+
tasks: Dict[str, str] = Field(default_factory=dict)
|
30
|
+
task_assignments: Dict[str, str] = Field(default_factory=dict)
|
31
|
+
triage_rules: Dict[str, str] = Field(default_factory=dict)
|
32
|
+
organizations: Dict[str, str] = Field(default_factory=dict)
|
33
|
+
users: Dict[str, str] = Field(default_factory=dict)
|
34
|
+
repositories: Dict[str, str] = Field(default_factory=dict)
|
35
|
+
|
36
|
+
|
37
|
+
class DefaultFiltersConfig(BaseModel):
|
38
|
+
"""Default filter configuration."""
|
39
|
+
organization_ids: List[int] = Field(default=[0])
|
40
|
+
all_organizations: bool = Field(default=True)
|
41
|
+
managed_status: List[str] = Field(default=["managed"])
|
42
|
+
online_status: List[str] = Field(default=["online"])
|
43
|
+
sort_type: str = Field(default="ASC")
|
44
|
+
|
45
|
+
|
46
|
+
class TaskDefaultsConfig(BaseModel):
|
47
|
+
"""Task default configuration."""
|
48
|
+
cpu_limit: int = Field(default=80)
|
49
|
+
enable_compression: bool = Field(default=True)
|
50
|
+
enable_encryption: bool = Field(default=False)
|
51
|
+
bandwidth_limit: int = Field(default=100000)
|
52
|
+
chunk_size: int = Field(default=1048576)
|
53
|
+
chunk_count: int = Field(default=0)
|
54
|
+
start_offset: int = Field(default=0)
|
55
|
+
|
56
|
+
|
57
|
+
class LoggingConfig(BaseModel):
|
58
|
+
"""Logging configuration."""
|
59
|
+
level: str = Field(default="INFO")
|
60
|
+
format: str = Field(default="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
61
|
+
file: str = Field(default="air_sdk.log")
|
62
|
+
max_file_size: str = Field(default="10MB")
|
63
|
+
backup_count: int = Field(default=5)
|
64
|
+
|
65
|
+
|
66
|
+
class CacheConfig(BaseModel):
|
67
|
+
"""Cache configuration."""
|
68
|
+
enabled: bool = Field(default=True)
|
69
|
+
ttl: int = Field(default=300)
|
70
|
+
max_size: int = Field(default=1000)
|
71
|
+
|
72
|
+
|
73
|
+
class RateLimitingConfig(BaseModel):
|
74
|
+
"""Rate limiting configuration."""
|
75
|
+
enabled: bool = Field(default=True)
|
76
|
+
requests_per_minute: int = Field(default=100)
|
77
|
+
burst_size: int = Field(default=10)
|
78
|
+
|
79
|
+
|
80
|
+
class AIRConfig(BaseModel):
|
81
|
+
"""Configuration for the AIR SDK."""
|
82
|
+
|
83
|
+
host: str = Field(..., description="AIR instance host URL")
|
84
|
+
api_token: str = Field(..., description="API token for authentication")
|
85
|
+
api_prefix: str = Field(default="api/public", description="API prefix path")
|
86
|
+
organization_id: int = Field(default=0, description="Default organization ID")
|
87
|
+
timeout: int = Field(default=30, description="Request timeout in seconds")
|
88
|
+
verify_ssl: bool = Field(default=True, description="Whether to verify SSL certificates")
|
89
|
+
retry_attempts: int = Field(default=3, description="Number of retry attempts for failed requests")
|
90
|
+
retry_delay: float = Field(default=1.0, description="Delay between retry attempts")
|
91
|
+
|
92
|
+
# Enhanced configuration sections
|
93
|
+
pagination: PaginationConfig = Field(default_factory=PaginationConfig)
|
94
|
+
endpoints: EndpointConfig = Field(default_factory=EndpointConfig)
|
95
|
+
default_filters: DefaultFiltersConfig = Field(default_factory=DefaultFiltersConfig)
|
96
|
+
task_defaults: TaskDefaultsConfig = Field(default_factory=TaskDefaultsConfig)
|
97
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
98
|
+
cache: CacheConfig = Field(default_factory=CacheConfig)
|
99
|
+
rate_limiting: RateLimitingConfig = Field(default_factory=RateLimitingConfig)
|
100
|
+
|
101
|
+
@field_validator("host")
|
102
|
+
@classmethod
|
103
|
+
def validate_host(cls, v):
|
104
|
+
"""Ensure host URL is properly formatted."""
|
105
|
+
if not v.startswith(("http://", "https://")):
|
106
|
+
raise ValueError("Host must start with http:// or https://")
|
107
|
+
return v.rstrip("/")
|
108
|
+
|
109
|
+
@field_validator("api_token")
|
110
|
+
@classmethod
|
111
|
+
def validate_api_token(cls, v):
|
112
|
+
"""Ensure API token is not empty."""
|
113
|
+
if not v or not v.strip():
|
114
|
+
raise ValueError("API token cannot be empty")
|
115
|
+
return v.strip()
|
116
|
+
|
117
|
+
@property
|
118
|
+
def base_url(self) -> str:
|
119
|
+
"""Get the full base URL for API requests."""
|
120
|
+
return f"{self.host}/{self.api_prefix}"
|
121
|
+
|
122
|
+
def get_endpoint(self, category: str, endpoint: str) -> str:
|
123
|
+
"""Get a specific endpoint URL."""
|
124
|
+
category_endpoints = getattr(self.endpoints, category, {})
|
125
|
+
if isinstance(category_endpoints, dict):
|
126
|
+
return category_endpoints.get(endpoint, "")
|
127
|
+
return ""
|
128
|
+
|
129
|
+
def get_full_endpoint_url(self, category: str, endpoint: str, **kwargs) -> str:
|
130
|
+
"""Get the full URL for an endpoint with substitutions."""
|
131
|
+
endpoint_path = self.get_endpoint(category, endpoint)
|
132
|
+
if not endpoint_path:
|
133
|
+
raise ValueError(f"Endpoint not found: {category}.{endpoint}")
|
134
|
+
|
135
|
+
# Substitute path parameters
|
136
|
+
full_path = endpoint_path.format(**kwargs)
|
137
|
+
return f"{self.base_url}{full_path}"
|
138
|
+
|
139
|
+
@classmethod
|
140
|
+
def from_environment(cls) -> "AIRConfig":
|
141
|
+
"""Create configuration from environment variables."""
|
142
|
+
config_data = {
|
143
|
+
"host": os.getenv("AIR_HOST", ""),
|
144
|
+
"api_token": os.getenv("AIR_API_TOKEN", ""),
|
145
|
+
"api_prefix": os.getenv("AIR_API_PREFIX", "api/public"),
|
146
|
+
"organization_id": int(os.getenv("AIR_ORGANIZATION_ID", "0")),
|
147
|
+
"timeout": int(os.getenv("AIR_TIMEOUT", "30")),
|
148
|
+
"verify_ssl": os.getenv("AIR_VERIFY_SSL", "true").lower() == "true",
|
149
|
+
"retry_attempts": int(os.getenv("AIR_RETRY_ATTEMPTS", "3")),
|
150
|
+
"retry_delay": float(os.getenv("AIR_RETRY_DELAY", "1.0")),
|
151
|
+
}
|
152
|
+
|
153
|
+
return cls(**config_data)
|
154
|
+
|
155
|
+
@classmethod
|
156
|
+
def from_file(cls, config_path: str = "config.json") -> "AIRConfig":
|
157
|
+
"""Create configuration from JSON file."""
|
158
|
+
try:
|
159
|
+
with open(config_path, "r") as f:
|
160
|
+
config_data = json.load(f)
|
161
|
+
return cls(**config_data)
|
162
|
+
except FileNotFoundError:
|
163
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
164
|
+
except json.JSONDecodeError as e:
|
165
|
+
raise ValueError(f"Invalid JSON in configuration file: {e}")
|
166
|
+
|
167
|
+
@classmethod
|
168
|
+
def create(
|
169
|
+
cls,
|
170
|
+
host: Optional[str] = None,
|
171
|
+
api_token: Optional[str] = None,
|
172
|
+
organization_id: Optional[int] = None,
|
173
|
+
config_file: Optional[str] = None,
|
174
|
+
**kwargs
|
175
|
+
) -> "AIRConfig":
|
176
|
+
"""Create configuration with precedence: params > env vars > config file."""
|
177
|
+
config_data = {}
|
178
|
+
|
179
|
+
# 1. Try config file first (lowest precedence)
|
180
|
+
if config_file and os.path.exists(config_file):
|
181
|
+
try:
|
182
|
+
with open(config_file, "r") as f:
|
183
|
+
config_data = json.load(f)
|
184
|
+
except (json.JSONDecodeError, IOError):
|
185
|
+
pass # Ignore file errors, will try other methods
|
186
|
+
elif os.path.exists("config.json"):
|
187
|
+
# Try default config.json
|
188
|
+
try:
|
189
|
+
with open("config.json", "r") as f:
|
190
|
+
config_data = json.load(f)
|
191
|
+
except (json.JSONDecodeError, IOError):
|
192
|
+
pass
|
193
|
+
|
194
|
+
# 2. Override with environment variables
|
195
|
+
env_config = {}
|
196
|
+
if os.getenv("AIR_HOST"):
|
197
|
+
env_config["host"] = os.getenv("AIR_HOST")
|
198
|
+
if os.getenv("AIR_API_TOKEN"):
|
199
|
+
env_config["api_token"] = os.getenv("AIR_API_TOKEN")
|
200
|
+
if os.getenv("AIR_API_PREFIX"):
|
201
|
+
env_config["api_prefix"] = os.getenv("AIR_API_PREFIX")
|
202
|
+
|
203
|
+
org_id_env = os.getenv("AIR_ORGANIZATION_ID")
|
204
|
+
if org_id_env:
|
205
|
+
env_config["organization_id"] = int(org_id_env)
|
206
|
+
|
207
|
+
timeout_env = os.getenv("AIR_TIMEOUT")
|
208
|
+
if timeout_env:
|
209
|
+
env_config["timeout"] = int(timeout_env)
|
210
|
+
|
211
|
+
verify_ssl_env = os.getenv("AIR_VERIFY_SSL")
|
212
|
+
if verify_ssl_env:
|
213
|
+
env_config["verify_ssl"] = verify_ssl_env.lower() == "true"
|
214
|
+
|
215
|
+
retry_attempts_env = os.getenv("AIR_RETRY_ATTEMPTS")
|
216
|
+
if retry_attempts_env:
|
217
|
+
env_config["retry_attempts"] = int(retry_attempts_env)
|
218
|
+
|
219
|
+
retry_delay_env = os.getenv("AIR_RETRY_DELAY")
|
220
|
+
if retry_delay_env:
|
221
|
+
env_config["retry_delay"] = float(retry_delay_env)
|
222
|
+
|
223
|
+
config_data.update(env_config)
|
224
|
+
|
225
|
+
# 3. Override with explicit parameters (highest precedence)
|
226
|
+
if host is not None:
|
227
|
+
config_data["host"] = host
|
228
|
+
if api_token is not None:
|
229
|
+
config_data["api_token"] = api_token
|
230
|
+
if organization_id is not None:
|
231
|
+
config_data["organization_id"] = organization_id
|
232
|
+
|
233
|
+
# Add any additional kwargs
|
234
|
+
config_data.update(kwargs)
|
235
|
+
|
236
|
+
return cls(**config_data)
|
237
|
+
|
238
|
+
def to_dict(self) -> Dict[str, Any]:
|
239
|
+
"""Convert configuration to dictionary."""
|
240
|
+
return self.model_dump()
|
241
|
+
|
242
|
+
def save_to_file(self, config_path: str = "config.json") -> None:
|
243
|
+
"""Save configuration to JSON file."""
|
244
|
+
with open(config_path, "w") as f:
|
245
|
+
json.dump(self.to_dict(), f, indent=2)
|
@@ -0,0 +1,50 @@
|
|
1
|
+
"""
|
2
|
+
Custom exceptions for the Binalyze AIR SDK.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import Optional, Dict, Any
|
6
|
+
|
7
|
+
|
8
|
+
class AIRAPIError(Exception):
|
9
|
+
"""Base exception for all AIR API errors."""
|
10
|
+
|
11
|
+
def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[Dict[str, Any]] = None):
|
12
|
+
self.message = message
|
13
|
+
self.status_code = status_code
|
14
|
+
self.response_data = response_data or {}
|
15
|
+
super().__init__(self.message)
|
16
|
+
|
17
|
+
|
18
|
+
class AuthenticationError(AIRAPIError):
|
19
|
+
"""Raised when authentication fails."""
|
20
|
+
pass
|
21
|
+
|
22
|
+
|
23
|
+
class AuthorizationError(AIRAPIError):
|
24
|
+
"""Raised when authorization fails."""
|
25
|
+
pass
|
26
|
+
|
27
|
+
|
28
|
+
class NotFoundError(AIRAPIError):
|
29
|
+
"""Raised when a resource is not found."""
|
30
|
+
pass
|
31
|
+
|
32
|
+
|
33
|
+
class ValidationError(AIRAPIError):
|
34
|
+
"""Raised when request validation fails."""
|
35
|
+
pass
|
36
|
+
|
37
|
+
|
38
|
+
class RateLimitError(AIRAPIError):
|
39
|
+
"""Raised when rate limit is exceeded."""
|
40
|
+
pass
|
41
|
+
|
42
|
+
|
43
|
+
class ServerError(AIRAPIError):
|
44
|
+
"""Raised when server returns 5xx status codes."""
|
45
|
+
pass
|
46
|
+
|
47
|
+
|
48
|
+
class NetworkError(AIRAPIError):
|
49
|
+
"""Raised when network-related errors occur."""
|
50
|
+
pass
|
@@ -0,0 +1,306 @@
|
|
1
|
+
"""
|
2
|
+
HTTP client for Binalyze AIR API communications.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import time
|
6
|
+
import requests
|
7
|
+
import urllib3
|
8
|
+
from typing import Any, Dict, Optional, Union
|
9
|
+
from urllib.parse import urljoin
|
10
|
+
|
11
|
+
from .config import AIRConfig
|
12
|
+
from .exceptions import (
|
13
|
+
AIRAPIError,
|
14
|
+
AuthenticationError,
|
15
|
+
AuthorizationError,
|
16
|
+
NotFoundError,
|
17
|
+
ValidationError,
|
18
|
+
RateLimitError,
|
19
|
+
ServerError,
|
20
|
+
NetworkError,
|
21
|
+
)
|
22
|
+
|
23
|
+
|
24
|
+
class HTTPClient:
|
25
|
+
"""HTTP client for AIR API communications."""
|
26
|
+
|
27
|
+
def __init__(self, config: AIRConfig):
|
28
|
+
"""Initialize the HTTP client with configuration."""
|
29
|
+
self.config = config
|
30
|
+
self.session = requests.Session()
|
31
|
+
self.session.headers.update({
|
32
|
+
"Content-Type": "application/json",
|
33
|
+
"Authorization": f"Bearer {config.api_token}",
|
34
|
+
"User-Agent": "binalyze-air-sdk/1.0.0",
|
35
|
+
})
|
36
|
+
self.session.verify = config.verify_ssl
|
37
|
+
|
38
|
+
# Disable SSL warnings when SSL verification is disabled
|
39
|
+
if not config.verify_ssl:
|
40
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
41
|
+
|
42
|
+
def _build_url(self, endpoint: str) -> str:
|
43
|
+
"""Build full URL from endpoint."""
|
44
|
+
# Remove leading slash if present
|
45
|
+
endpoint = endpoint.lstrip("/")
|
46
|
+
# Build full URL with API prefix
|
47
|
+
return f"{self.config.host}/{self.config.api_prefix}/{endpoint}"
|
48
|
+
|
49
|
+
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
50
|
+
"""Handle HTTP response and raise appropriate exceptions."""
|
51
|
+
try:
|
52
|
+
data = response.json()
|
53
|
+
except ValueError:
|
54
|
+
# If response is not JSON, create a basic structure
|
55
|
+
data = {
|
56
|
+
"success": False,
|
57
|
+
"result": None,
|
58
|
+
"statusCode": response.status_code,
|
59
|
+
"errors": [response.text or "Unknown error"]
|
60
|
+
}
|
61
|
+
|
62
|
+
# Handle specific known API bugs with better error messages
|
63
|
+
if response.status_code == 500:
|
64
|
+
error_message = data.get("errors", [""])[0] if data.get("errors") else ""
|
65
|
+
|
66
|
+
# API-001: Policies endpoint parameter validation bug
|
67
|
+
if "GET: /api/public/policies route has internal server error" in error_message:
|
68
|
+
raise ValidationError(
|
69
|
+
"Missing required 'organizationIds' filter parameter. "
|
70
|
+
"Please provide organization IDs to filter policies. "
|
71
|
+
"(Note: This is a known API server bug that returns 500 instead of 400)",
|
72
|
+
status_code=400, # What it should be
|
73
|
+
response_data=data
|
74
|
+
)
|
75
|
+
|
76
|
+
# API-002: Auto asset tags update endpoint bug
|
77
|
+
if "auto-asset-tag" in response.url and response.request.method == "PUT":
|
78
|
+
raise ServerError(
|
79
|
+
"Auto asset tag update is currently unavailable due to a server bug. "
|
80
|
+
"Workaround: Delete the existing tag and create a new one with updated values. "
|
81
|
+
"(Note: This is a known API server issue)",
|
82
|
+
status_code=response.status_code,
|
83
|
+
response_data=data
|
84
|
+
)
|
85
|
+
|
86
|
+
# Check for specific error status codes
|
87
|
+
if response.status_code == 401:
|
88
|
+
raise AuthenticationError(
|
89
|
+
"Authentication failed. Check your API token.",
|
90
|
+
status_code=response.status_code,
|
91
|
+
response_data=data
|
92
|
+
)
|
93
|
+
elif response.status_code == 403:
|
94
|
+
raise AuthorizationError(
|
95
|
+
"Authorization failed. Insufficient permissions.",
|
96
|
+
status_code=response.status_code,
|
97
|
+
response_data=data
|
98
|
+
)
|
99
|
+
elif response.status_code == 404:
|
100
|
+
# Use specific API error message instead of generic message
|
101
|
+
api_errors = data.get("errors", ["Resource not found"])
|
102
|
+
error_message = "; ".join(api_errors) if api_errors else "Resource not found"
|
103
|
+
raise NotFoundError(
|
104
|
+
error_message,
|
105
|
+
status_code=response.status_code,
|
106
|
+
response_data=data
|
107
|
+
)
|
108
|
+
elif response.status_code == 422:
|
109
|
+
errors = data.get("errors", ["Validation failed"])
|
110
|
+
raise ValidationError(
|
111
|
+
f"Validation error: {'; '.join(errors)}",
|
112
|
+
status_code=response.status_code,
|
113
|
+
response_data=data
|
114
|
+
)
|
115
|
+
elif response.status_code == 429:
|
116
|
+
raise RateLimitError(
|
117
|
+
"Rate limit exceeded. Please try again later.",
|
118
|
+
status_code=response.status_code,
|
119
|
+
response_data=data
|
120
|
+
)
|
121
|
+
elif response.status_code >= 500:
|
122
|
+
raise ServerError(
|
123
|
+
f"Server error: {response.status_code}",
|
124
|
+
status_code=response.status_code,
|
125
|
+
response_data=data
|
126
|
+
)
|
127
|
+
elif not response.ok:
|
128
|
+
errors = data.get("errors", [f"HTTP {response.status_code}"])
|
129
|
+
raise AIRAPIError(
|
130
|
+
f"API error: {'; '.join(errors)}",
|
131
|
+
status_code=response.status_code,
|
132
|
+
response_data=data
|
133
|
+
)
|
134
|
+
|
135
|
+
return data
|
136
|
+
|
137
|
+
def _handle_binary_response(self, response: requests.Response) -> requests.Response:
|
138
|
+
"""Handle binary file response without JSON parsing."""
|
139
|
+
# Check for specific error status codes
|
140
|
+
if response.status_code == 401:
|
141
|
+
raise AuthenticationError(
|
142
|
+
"Authentication failed. Check your API token.",
|
143
|
+
status_code=response.status_code
|
144
|
+
)
|
145
|
+
elif response.status_code == 403:
|
146
|
+
raise AuthorizationError(
|
147
|
+
"Authorization failed. Insufficient permissions.",
|
148
|
+
status_code=response.status_code
|
149
|
+
)
|
150
|
+
elif response.status_code == 404:
|
151
|
+
raise NotFoundError(
|
152
|
+
"Resource not found.",
|
153
|
+
status_code=response.status_code
|
154
|
+
)
|
155
|
+
elif response.status_code == 422:
|
156
|
+
raise ValidationError(
|
157
|
+
"Validation error",
|
158
|
+
status_code=response.status_code
|
159
|
+
)
|
160
|
+
elif response.status_code == 429:
|
161
|
+
raise RateLimitError(
|
162
|
+
"Rate limit exceeded. Please try again later.",
|
163
|
+
status_code=response.status_code
|
164
|
+
)
|
165
|
+
elif response.status_code >= 500:
|
166
|
+
raise ServerError(
|
167
|
+
f"Server error: {response.status_code}",
|
168
|
+
status_code=response.status_code
|
169
|
+
)
|
170
|
+
elif not response.ok:
|
171
|
+
raise AIRAPIError(
|
172
|
+
f"API error: HTTP {response.status_code}",
|
173
|
+
status_code=response.status_code
|
174
|
+
)
|
175
|
+
|
176
|
+
return response
|
177
|
+
|
178
|
+
def _make_request(
|
179
|
+
self,
|
180
|
+
method: str,
|
181
|
+
endpoint: str,
|
182
|
+
params: Optional[Dict[str, Any]] = None,
|
183
|
+
data: Optional[Dict[str, Any]] = None,
|
184
|
+
json_data: Optional[Dict[str, Any]] = None
|
185
|
+
) -> Dict[str, Any]:
|
186
|
+
"""Make HTTP request with retry logic."""
|
187
|
+
url = self._build_url(endpoint)
|
188
|
+
last_exception = None
|
189
|
+
|
190
|
+
for attempt in range(self.config.retry_attempts):
|
191
|
+
try:
|
192
|
+
response = self.session.request(
|
193
|
+
method=method,
|
194
|
+
url=url,
|
195
|
+
params=params,
|
196
|
+
data=data,
|
197
|
+
json=json_data,
|
198
|
+
timeout=self.config.timeout
|
199
|
+
)
|
200
|
+
return self._handle_response(response)
|
201
|
+
|
202
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
203
|
+
last_exception = NetworkError(f"Network error: {str(e)}")
|
204
|
+
if attempt < self.config.retry_attempts - 1:
|
205
|
+
time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
|
206
|
+
continue
|
207
|
+
raise last_exception
|
208
|
+
|
209
|
+
except (RateLimitError, ServerError) as e:
|
210
|
+
last_exception = e
|
211
|
+
if attempt < self.config.retry_attempts - 1:
|
212
|
+
time.sleep(self.config.retry_delay * (2 ** attempt))
|
213
|
+
continue
|
214
|
+
raise
|
215
|
+
|
216
|
+
except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
|
217
|
+
# Don't retry these errors
|
218
|
+
raise
|
219
|
+
|
220
|
+
# If we get here, all retries failed
|
221
|
+
if last_exception:
|
222
|
+
raise last_exception
|
223
|
+
|
224
|
+
raise AIRAPIError("All retry attempts failed")
|
225
|
+
|
226
|
+
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
227
|
+
"""Make GET request."""
|
228
|
+
return self._make_request("GET", endpoint, params=params)
|
229
|
+
|
230
|
+
def post(
|
231
|
+
self,
|
232
|
+
endpoint: str,
|
233
|
+
data: Optional[Dict[str, Any]] = None,
|
234
|
+
json_data: Optional[Dict[str, Any]] = None,
|
235
|
+
params: Optional[Dict[str, Any]] = None
|
236
|
+
) -> Dict[str, Any]:
|
237
|
+
"""Make POST request."""
|
238
|
+
return self._make_request("POST", endpoint, params=params, data=data, json_data=json_data)
|
239
|
+
|
240
|
+
def put(
|
241
|
+
self,
|
242
|
+
endpoint: str,
|
243
|
+
data: Optional[Dict[str, Any]] = None,
|
244
|
+
json_data: Optional[Dict[str, Any]] = None,
|
245
|
+
params: Optional[Dict[str, Any]] = None
|
246
|
+
) -> Dict[str, Any]:
|
247
|
+
"""Make PUT request."""
|
248
|
+
return self._make_request("PUT", endpoint, params=params, data=data, json_data=json_data)
|
249
|
+
|
250
|
+
def patch(
|
251
|
+
self,
|
252
|
+
endpoint: str,
|
253
|
+
data: Optional[Dict[str, Any]] = None,
|
254
|
+
json_data: Optional[Dict[str, Any]] = None,
|
255
|
+
params: Optional[Dict[str, Any]] = None
|
256
|
+
) -> Dict[str, Any]:
|
257
|
+
"""Make PATCH request."""
|
258
|
+
return self._make_request("PATCH", endpoint, params=params, data=data, json_data=json_data)
|
259
|
+
|
260
|
+
def delete(
|
261
|
+
self,
|
262
|
+
endpoint: str,
|
263
|
+
params: Optional[Dict[str, Any]] = None,
|
264
|
+
json_data: Optional[Dict[str, Any]] = None
|
265
|
+
) -> Dict[str, Any]:
|
266
|
+
"""Make DELETE request."""
|
267
|
+
return self._make_request("DELETE", endpoint, params=params, json_data=json_data)
|
268
|
+
|
269
|
+
def get_binary(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> requests.Response:
|
270
|
+
"""Make GET request for binary file downloads."""
|
271
|
+
url = self._build_url(endpoint)
|
272
|
+
last_exception = None
|
273
|
+
|
274
|
+
for attempt in range(self.config.retry_attempts):
|
275
|
+
try:
|
276
|
+
response = self.session.request(
|
277
|
+
method="GET",
|
278
|
+
url=url,
|
279
|
+
params=params,
|
280
|
+
timeout=self.config.timeout
|
281
|
+
)
|
282
|
+
return self._handle_binary_response(response)
|
283
|
+
|
284
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
285
|
+
last_exception = NetworkError(f"Network error: {str(e)}")
|
286
|
+
if attempt < self.config.retry_attempts - 1:
|
287
|
+
time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
|
288
|
+
continue
|
289
|
+
raise last_exception
|
290
|
+
|
291
|
+
except (RateLimitError, ServerError) as e:
|
292
|
+
last_exception = e
|
293
|
+
if attempt < self.config.retry_attempts - 1:
|
294
|
+
time.sleep(self.config.retry_delay * (2 ** attempt))
|
295
|
+
continue
|
296
|
+
raise
|
297
|
+
|
298
|
+
except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
|
299
|
+
# Don't retry these errors
|
300
|
+
raise
|
301
|
+
|
302
|
+
# If we get here, all retries failed
|
303
|
+
if last_exception:
|
304
|
+
raise last_exception
|
305
|
+
|
306
|
+
raise AIRAPIError("All retry attempts failed")
|