malloryapi 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.
- malloryapi/__init__.py +27 -0
- malloryapi/_http.py +202 -0
- malloryapi/_pagination.py +56 -0
- malloryapi/_types.py +30 -0
- malloryapi/client.py +176 -0
- malloryapi/exceptions.py +35 -0
- malloryapi/resources/__init__.py +0 -0
- malloryapi/resources/_base.py +133 -0
- malloryapi/resources/attack_patterns.py +102 -0
- malloryapi/resources/breaches.py +64 -0
- malloryapi/resources/content_chunks.py +48 -0
- malloryapi/resources/detection_signatures.py +50 -0
- malloryapi/resources/exploitations.py +50 -0
- malloryapi/resources/exploits.py +84 -0
- malloryapi/resources/malware.py +134 -0
- malloryapi/resources/mentions.py +68 -0
- malloryapi/resources/organizations.py +114 -0
- malloryapi/resources/products.py +130 -0
- malloryapi/resources/references.py +128 -0
- malloryapi/resources/search.py +43 -0
- malloryapi/resources/sources.py +38 -0
- malloryapi/resources/stories.py +122 -0
- malloryapi/resources/technology_product_advisories.py +80 -0
- malloryapi/resources/threat_actors.py +120 -0
- malloryapi/resources/vulnerabilities.py +210 -0
- malloryapi/resources/weaknesses.py +50 -0
- malloryapi-0.1.0.dist-info/METADATA +179 -0
- malloryapi-0.1.0.dist-info/RECORD +30 -0
- malloryapi-0.1.0.dist-info/WHEEL +4 -0
- malloryapi-0.1.0.dist-info/licenses/LICENSE +191 -0
malloryapi/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""malloryapi - Official Python client for the Mallory API."""
|
|
2
|
+
|
|
3
|
+
from malloryapi._pagination import paginate_async, paginate_sync
|
|
4
|
+
from malloryapi._types import PaginatedResponse
|
|
5
|
+
from malloryapi.client import AsyncMalloryApi, MalloryApi
|
|
6
|
+
from malloryapi.exceptions import (
|
|
7
|
+
APIError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"MalloryApi",
|
|
16
|
+
"AsyncMalloryApi",
|
|
17
|
+
"PaginatedResponse",
|
|
18
|
+
"paginate_sync",
|
|
19
|
+
"paginate_async",
|
|
20
|
+
"APIError",
|
|
21
|
+
"AuthenticationError",
|
|
22
|
+
"NotFoundError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"ValidationError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
malloryapi/_http.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""HTTP client wrapper for sync and async requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from malloryapi.exceptions import (
|
|
11
|
+
APIError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.mallory.ai/v1"
|
|
19
|
+
DEFAULT_TIMEOUT = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
23
|
+
key = api_key or os.environ.get("MALLORY_API_KEY")
|
|
24
|
+
if not key:
|
|
25
|
+
raise AuthenticationError(
|
|
26
|
+
"No API key provided. Pass api_key= or set the "
|
|
27
|
+
"MALLORY_API_KEY environment variable."
|
|
28
|
+
)
|
|
29
|
+
return key
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_headers(api_key: str) -> dict[str, str]:
|
|
33
|
+
return {
|
|
34
|
+
"Authorization": f"Bearer {api_key}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"Accept": "application/json",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _handle_error_response(response: httpx.Response) -> None:
|
|
41
|
+
"""Raise the appropriate exception for non-2xx responses."""
|
|
42
|
+
status = response.status_code
|
|
43
|
+
try:
|
|
44
|
+
body = response.json()
|
|
45
|
+
except Exception:
|
|
46
|
+
body = response.text
|
|
47
|
+
|
|
48
|
+
if status in (401, 403):
|
|
49
|
+
raise AuthenticationError(
|
|
50
|
+
f"Authentication failed ({status})",
|
|
51
|
+
status_code=status,
|
|
52
|
+
response_body=body,
|
|
53
|
+
)
|
|
54
|
+
if status == 404:
|
|
55
|
+
raise NotFoundError(
|
|
56
|
+
"Resource not found",
|
|
57
|
+
status_code=status,
|
|
58
|
+
response_body=body,
|
|
59
|
+
)
|
|
60
|
+
if status == 422:
|
|
61
|
+
raise ValidationError(
|
|
62
|
+
f"Validation error: {body}",
|
|
63
|
+
status_code=status,
|
|
64
|
+
response_body=body,
|
|
65
|
+
)
|
|
66
|
+
if status == 429:
|
|
67
|
+
raise RateLimitError(
|
|
68
|
+
"Rate limit exceeded",
|
|
69
|
+
status_code=status,
|
|
70
|
+
response_body=body,
|
|
71
|
+
)
|
|
72
|
+
raise APIError(
|
|
73
|
+
f"API request failed ({status})",
|
|
74
|
+
status_code=status,
|
|
75
|
+
response_body=body,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SyncHttpClient:
|
|
80
|
+
"""Synchronous HTTP client backed by httpx."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: str | None = None,
|
|
85
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
86
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
87
|
+
) -> None:
|
|
88
|
+
resolved_key = _resolve_api_key(api_key)
|
|
89
|
+
self.base_url = base_url.rstrip("/")
|
|
90
|
+
self._client = httpx.Client(
|
|
91
|
+
base_url=self.base_url,
|
|
92
|
+
headers=_build_headers(resolved_key),
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def get(
|
|
97
|
+
self,
|
|
98
|
+
path: str,
|
|
99
|
+
params: dict[str, Any] | None = None,
|
|
100
|
+
) -> Any:
|
|
101
|
+
response = self._client.get(path, params=params)
|
|
102
|
+
if response.status_code >= 400:
|
|
103
|
+
_handle_error_response(response)
|
|
104
|
+
return response.json()
|
|
105
|
+
|
|
106
|
+
def post(
|
|
107
|
+
self,
|
|
108
|
+
path: str,
|
|
109
|
+
json: Any = None,
|
|
110
|
+
params: dict[str, Any] | None = None,
|
|
111
|
+
) -> Any:
|
|
112
|
+
response = self._client.post(path, json=json, params=params)
|
|
113
|
+
if response.status_code >= 400:
|
|
114
|
+
_handle_error_response(response)
|
|
115
|
+
return response.json()
|
|
116
|
+
|
|
117
|
+
def patch(
|
|
118
|
+
self,
|
|
119
|
+
path: str,
|
|
120
|
+
json: Any = None,
|
|
121
|
+
) -> Any:
|
|
122
|
+
response = self._client.patch(path, json=json)
|
|
123
|
+
if response.status_code >= 400:
|
|
124
|
+
_handle_error_response(response)
|
|
125
|
+
return response.json()
|
|
126
|
+
|
|
127
|
+
def delete(
|
|
128
|
+
self,
|
|
129
|
+
path: str,
|
|
130
|
+
params: dict[str, Any] | None = None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
response = self._client.delete(path, params=params)
|
|
133
|
+
if response.status_code >= 400:
|
|
134
|
+
_handle_error_response(response)
|
|
135
|
+
return response.json()
|
|
136
|
+
|
|
137
|
+
def close(self) -> None:
|
|
138
|
+
self._client.close()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AsyncHttpClient:
|
|
142
|
+
"""Asynchronous HTTP client backed by httpx."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
api_key: str | None = None,
|
|
147
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
148
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
149
|
+
) -> None:
|
|
150
|
+
resolved_key = _resolve_api_key(api_key)
|
|
151
|
+
self.base_url = base_url.rstrip("/")
|
|
152
|
+
self._client = httpx.AsyncClient(
|
|
153
|
+
base_url=self.base_url,
|
|
154
|
+
headers=_build_headers(resolved_key),
|
|
155
|
+
timeout=timeout,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def get(
|
|
159
|
+
self,
|
|
160
|
+
path: str,
|
|
161
|
+
params: dict[str, Any] | None = None,
|
|
162
|
+
) -> Any:
|
|
163
|
+
response = await self._client.get(path, params=params)
|
|
164
|
+
if response.status_code >= 400:
|
|
165
|
+
_handle_error_response(response)
|
|
166
|
+
return response.json()
|
|
167
|
+
|
|
168
|
+
async def post(
|
|
169
|
+
self,
|
|
170
|
+
path: str,
|
|
171
|
+
json: Any = None,
|
|
172
|
+
params: dict[str, Any] | None = None,
|
|
173
|
+
) -> Any:
|
|
174
|
+
response = await self._client.post(
|
|
175
|
+
path, json=json, params=params
|
|
176
|
+
)
|
|
177
|
+
if response.status_code >= 400:
|
|
178
|
+
_handle_error_response(response)
|
|
179
|
+
return response.json()
|
|
180
|
+
|
|
181
|
+
async def patch(
|
|
182
|
+
self,
|
|
183
|
+
path: str,
|
|
184
|
+
json: Any = None,
|
|
185
|
+
) -> Any:
|
|
186
|
+
response = await self._client.patch(path, json=json)
|
|
187
|
+
if response.status_code >= 400:
|
|
188
|
+
_handle_error_response(response)
|
|
189
|
+
return response.json()
|
|
190
|
+
|
|
191
|
+
async def delete(
|
|
192
|
+
self,
|
|
193
|
+
path: str,
|
|
194
|
+
params: dict[str, Any] | None = None,
|
|
195
|
+
) -> Any:
|
|
196
|
+
response = await self._client.delete(path, params=params)
|
|
197
|
+
if response.status_code >= 400:
|
|
198
|
+
_handle_error_response(response)
|
|
199
|
+
return response.json()
|
|
200
|
+
|
|
201
|
+
async def aclose(self) -> None:
|
|
202
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Auto-pagination iterators for sync and async usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, AsyncIterator, Callable, Iterator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def paginate_sync(
|
|
9
|
+
fetch: Callable[..., Any],
|
|
10
|
+
limit: int = 100,
|
|
11
|
+
**kwargs: Any,
|
|
12
|
+
) -> Iterator[dict[str, Any]]:
|
|
13
|
+
"""Yield all items across pages synchronously.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
for vuln in paginate_sync(client.vulnerabilities.list, limit=50):
|
|
18
|
+
print(vuln["cve_id"])
|
|
19
|
+
"""
|
|
20
|
+
offset = 0
|
|
21
|
+
while True:
|
|
22
|
+
page = fetch(offset=offset, limit=limit, **kwargs)
|
|
23
|
+
items = page.items if hasattr(page, "items") else page
|
|
24
|
+
if not items:
|
|
25
|
+
break
|
|
26
|
+
yield from items
|
|
27
|
+
offset += limit
|
|
28
|
+
if not page.has_more:
|
|
29
|
+
break
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def paginate_async(
|
|
33
|
+
fetch: Callable[..., Any],
|
|
34
|
+
limit: int = 100,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
37
|
+
"""Yield all items across pages asynchronously.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
async for vuln in paginate_async(
|
|
42
|
+
client.vulnerabilities.list, limit=50
|
|
43
|
+
):
|
|
44
|
+
print(vuln["cve_id"])
|
|
45
|
+
"""
|
|
46
|
+
offset = 0
|
|
47
|
+
while True:
|
|
48
|
+
page = await fetch(offset=offset, limit=limit, **kwargs)
|
|
49
|
+
items = page.items if hasattr(page, "items") else page
|
|
50
|
+
if not items:
|
|
51
|
+
break
|
|
52
|
+
for item in items:
|
|
53
|
+
yield item
|
|
54
|
+
offset += limit
|
|
55
|
+
if not page.has_more:
|
|
56
|
+
break
|
malloryapi/_types.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared types for the Mallory API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PaginatedResponse:
|
|
11
|
+
"""Response from a paginated list endpoint."""
|
|
12
|
+
|
|
13
|
+
items: list[dict[str, Any]] = field(default_factory=list)
|
|
14
|
+
total: int = 0
|
|
15
|
+
offset: int = 0
|
|
16
|
+
limit: int = 100
|
|
17
|
+
|
|
18
|
+
def __len__(self) -> int:
|
|
19
|
+
return len(self.items)
|
|
20
|
+
|
|
21
|
+
def __iter__(self):
|
|
22
|
+
return iter(self.items)
|
|
23
|
+
|
|
24
|
+
def __getitem__(self, index):
|
|
25
|
+
return self.items[index]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def has_more(self) -> bool:
|
|
29
|
+
"""Whether there are more pages available."""
|
|
30
|
+
return self.offset + self.limit < self.total
|
malloryapi/client.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Main client classes for the Mallory API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from malloryapi._http import (
|
|
6
|
+
DEFAULT_BASE_URL,
|
|
7
|
+
DEFAULT_TIMEOUT,
|
|
8
|
+
AsyncHttpClient,
|
|
9
|
+
SyncHttpClient,
|
|
10
|
+
)
|
|
11
|
+
from malloryapi.resources.attack_patterns import (
|
|
12
|
+
AsyncAttackPatterns,
|
|
13
|
+
AttackPatterns,
|
|
14
|
+
)
|
|
15
|
+
from malloryapi.resources.breaches import AsyncBreaches, Breaches
|
|
16
|
+
from malloryapi.resources.content_chunks import (
|
|
17
|
+
AsyncContentChunks,
|
|
18
|
+
ContentChunks,
|
|
19
|
+
)
|
|
20
|
+
from malloryapi.resources.detection_signatures import (
|
|
21
|
+
AsyncDetectionSignatures,
|
|
22
|
+
DetectionSignatures,
|
|
23
|
+
)
|
|
24
|
+
from malloryapi.resources.exploitations import (
|
|
25
|
+
AsyncExploitations,
|
|
26
|
+
Exploitations,
|
|
27
|
+
)
|
|
28
|
+
from malloryapi.resources.exploits import AsyncExploits, Exploits
|
|
29
|
+
from malloryapi.resources.malware import AsyncMalware, Malware
|
|
30
|
+
from malloryapi.resources.mentions import AsyncMentions, Mentions
|
|
31
|
+
from malloryapi.resources.organizations import (
|
|
32
|
+
AsyncOrganizations,
|
|
33
|
+
Organizations,
|
|
34
|
+
)
|
|
35
|
+
from malloryapi.resources.products import AsyncProducts, Products
|
|
36
|
+
from malloryapi.resources.references import (
|
|
37
|
+
AsyncReferences,
|
|
38
|
+
References,
|
|
39
|
+
)
|
|
40
|
+
from malloryapi.resources.search import AsyncSearch, Search
|
|
41
|
+
from malloryapi.resources.sources import AsyncSources, Sources
|
|
42
|
+
from malloryapi.resources.stories import AsyncStories, Stories
|
|
43
|
+
from malloryapi.resources.technology_product_advisories import (
|
|
44
|
+
AsyncTechnologyProductAdvisories,
|
|
45
|
+
TechnologyProductAdvisories,
|
|
46
|
+
)
|
|
47
|
+
from malloryapi.resources.threat_actors import (
|
|
48
|
+
AsyncThreatActors,
|
|
49
|
+
ThreatActors,
|
|
50
|
+
)
|
|
51
|
+
from malloryapi.resources.vulnerabilities import (
|
|
52
|
+
AsyncVulnerabilities,
|
|
53
|
+
Vulnerabilities,
|
|
54
|
+
)
|
|
55
|
+
from malloryapi.resources.weaknesses import (
|
|
56
|
+
AsyncWeaknesses,
|
|
57
|
+
Weaknesses,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MalloryApi:
|
|
62
|
+
"""Synchronous client for the Mallory threat intelligence API.
|
|
63
|
+
|
|
64
|
+
Usage::
|
|
65
|
+
|
|
66
|
+
from malloryapi import MalloryApi
|
|
67
|
+
|
|
68
|
+
client = MalloryApi(api_key="sk-...")
|
|
69
|
+
vulns = client.vulnerabilities.list(limit=10)
|
|
70
|
+
actor = client.threat_actors.get("apt28-uuid")
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
api_key: str | None = None,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._http = SyncHttpClient(
|
|
80
|
+
api_key=api_key, base_url=base_url, timeout=timeout
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Entities
|
|
84
|
+
self.vulnerabilities = Vulnerabilities(self._http)
|
|
85
|
+
self.threat_actors = ThreatActors(self._http)
|
|
86
|
+
self.malware = Malware(self._http)
|
|
87
|
+
self.exploits = Exploits(self._http)
|
|
88
|
+
self.exploitations = Exploitations(self._http)
|
|
89
|
+
self.organizations = Organizations(self._http)
|
|
90
|
+
self.products = Products(self._http)
|
|
91
|
+
self.attack_patterns = AttackPatterns(self._http)
|
|
92
|
+
self.breaches = Breaches(self._http)
|
|
93
|
+
self.detection_signatures = DetectionSignatures(self._http)
|
|
94
|
+
self.advisories = TechnologyProductAdvisories(self._http)
|
|
95
|
+
self.weaknesses = Weaknesses(self._http)
|
|
96
|
+
|
|
97
|
+
# Content
|
|
98
|
+
self.stories = Stories(self._http)
|
|
99
|
+
self.references = References(self._http)
|
|
100
|
+
self.sources = Sources(self._http)
|
|
101
|
+
self.content_chunks = ContentChunks(self._http)
|
|
102
|
+
|
|
103
|
+
# Analytics
|
|
104
|
+
self.mentions = Mentions(self._http)
|
|
105
|
+
self.search = Search(self._http)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Close the underlying HTTP connection."""
|
|
109
|
+
self._http.close()
|
|
110
|
+
|
|
111
|
+
def __enter__(self) -> MalloryApi:
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(self, *args) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AsyncMalloryApi:
|
|
119
|
+
"""Asynchronous client for the Mallory threat intelligence API.
|
|
120
|
+
|
|
121
|
+
Usage::
|
|
122
|
+
|
|
123
|
+
from malloryapi import AsyncMalloryApi
|
|
124
|
+
|
|
125
|
+
async with AsyncMalloryApi(api_key="sk-...") as client:
|
|
126
|
+
vulns = await client.vulnerabilities.list(limit=10)
|
|
127
|
+
actor = await client.threat_actors.get("apt28-uuid")
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
api_key: str | None = None,
|
|
133
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
134
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
135
|
+
) -> None:
|
|
136
|
+
self._http = AsyncHttpClient(
|
|
137
|
+
api_key=api_key, base_url=base_url, timeout=timeout
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Entities
|
|
141
|
+
self.vulnerabilities = AsyncVulnerabilities(self._http)
|
|
142
|
+
self.threat_actors = AsyncThreatActors(self._http)
|
|
143
|
+
self.malware = AsyncMalware(self._http)
|
|
144
|
+
self.exploits = AsyncExploits(self._http)
|
|
145
|
+
self.exploitations = AsyncExploitations(self._http)
|
|
146
|
+
self.organizations = AsyncOrganizations(self._http)
|
|
147
|
+
self.products = AsyncProducts(self._http)
|
|
148
|
+
self.attack_patterns = AsyncAttackPatterns(self._http)
|
|
149
|
+
self.breaches = AsyncBreaches(self._http)
|
|
150
|
+
self.detection_signatures = AsyncDetectionSignatures(
|
|
151
|
+
self._http
|
|
152
|
+
)
|
|
153
|
+
self.advisories = AsyncTechnologyProductAdvisories(
|
|
154
|
+
self._http
|
|
155
|
+
)
|
|
156
|
+
self.weaknesses = AsyncWeaknesses(self._http)
|
|
157
|
+
|
|
158
|
+
# Content
|
|
159
|
+
self.stories = AsyncStories(self._http)
|
|
160
|
+
self.references = AsyncReferences(self._http)
|
|
161
|
+
self.sources = AsyncSources(self._http)
|
|
162
|
+
self.content_chunks = AsyncContentChunks(self._http)
|
|
163
|
+
|
|
164
|
+
# Analytics
|
|
165
|
+
self.mentions = AsyncMentions(self._http)
|
|
166
|
+
self.search = AsyncSearch(self._http)
|
|
167
|
+
|
|
168
|
+
async def aclose(self) -> None:
|
|
169
|
+
"""Close the underlying HTTP connection."""
|
|
170
|
+
await self._http.aclose()
|
|
171
|
+
|
|
172
|
+
async def __aenter__(self) -> AsyncMalloryApi:
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
async def __aexit__(self, *args) -> None:
|
|
176
|
+
await self.aclose()
|
malloryapi/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exceptions raised by the Mallory API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(Exception):
|
|
9
|
+
"""Base exception for all Mallory API errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
status_code: int | None = None,
|
|
15
|
+
response_body: Any = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.response_body = response_body
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthenticationError(APIError):
|
|
23
|
+
"""Raised on 401 or 403 responses."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(APIError):
|
|
27
|
+
"""Raised on 404 responses."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationError(APIError):
|
|
31
|
+
"""Raised on 422 responses."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RateLimitError(APIError):
|
|
35
|
+
"""Raised on 429 responses."""
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Base resource classes for sync and async API access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from malloryapi._http import AsyncHttpClient, SyncHttpClient
|
|
8
|
+
from malloryapi._types import PaginatedResponse
|
|
9
|
+
|
|
10
|
+
TrendingPeriod = Literal["1d", "7d", "30d"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SyncResource:
|
|
14
|
+
"""Base class for synchronous resource clients."""
|
|
15
|
+
|
|
16
|
+
_path: str # e.g. "/vulnerabilities"
|
|
17
|
+
|
|
18
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
19
|
+
self._http = http
|
|
20
|
+
|
|
21
|
+
# -- common helpers ------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _list(
|
|
24
|
+
self,
|
|
25
|
+
params: dict[str, Any] | None = None,
|
|
26
|
+
**extra: Any,
|
|
27
|
+
) -> PaginatedResponse:
|
|
28
|
+
merged = {**(params or {}), **extra}
|
|
29
|
+
merged = {k: v for k, v in merged.items() if v is not None}
|
|
30
|
+
data = self._http.get(self._path, params=merged)
|
|
31
|
+
return _parse_paginated(data)
|
|
32
|
+
|
|
33
|
+
def _get(self, identifier: str) -> dict[str, Any]:
|
|
34
|
+
return self._http.get(f"{self._path}/{identifier}")
|
|
35
|
+
|
|
36
|
+
def _sub(
|
|
37
|
+
self,
|
|
38
|
+
identifier: str,
|
|
39
|
+
sub: str,
|
|
40
|
+
params: dict[str, Any] | None = None,
|
|
41
|
+
) -> Any:
|
|
42
|
+
return self._http.get(
|
|
43
|
+
f"{self._path}/{identifier}/{sub}", params=params
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _post(
|
|
47
|
+
self,
|
|
48
|
+
path: str | None = None,
|
|
49
|
+
json: Any = None,
|
|
50
|
+
params: dict[str, Any] | None = None,
|
|
51
|
+
) -> Any:
|
|
52
|
+
return self._http.post(
|
|
53
|
+
path or self._path, json=json, params=params
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _patch(
|
|
57
|
+
self,
|
|
58
|
+
identifier: str,
|
|
59
|
+
json: Any = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
return self._http.patch(
|
|
62
|
+
f"{self._path}/{identifier}", json=json
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AsyncResource:
|
|
67
|
+
"""Base class for asynchronous resource clients."""
|
|
68
|
+
|
|
69
|
+
_path: str
|
|
70
|
+
|
|
71
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
72
|
+
self._http = http
|
|
73
|
+
|
|
74
|
+
async def _list(
|
|
75
|
+
self,
|
|
76
|
+
params: dict[str, Any] | None = None,
|
|
77
|
+
**extra: Any,
|
|
78
|
+
) -> PaginatedResponse:
|
|
79
|
+
merged = {**(params or {}), **extra}
|
|
80
|
+
merged = {k: v for k, v in merged.items() if v is not None}
|
|
81
|
+
data = await self._http.get(self._path, params=merged)
|
|
82
|
+
return _parse_paginated(data)
|
|
83
|
+
|
|
84
|
+
async def _get(self, identifier: str) -> dict[str, Any]:
|
|
85
|
+
return await self._http.get(f"{self._path}/{identifier}")
|
|
86
|
+
|
|
87
|
+
async def _sub(
|
|
88
|
+
self,
|
|
89
|
+
identifier: str,
|
|
90
|
+
sub: str,
|
|
91
|
+
params: dict[str, Any] | None = None,
|
|
92
|
+
) -> Any:
|
|
93
|
+
return await self._http.get(
|
|
94
|
+
f"{self._path}/{identifier}/{sub}", params=params
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def _post(
|
|
98
|
+
self,
|
|
99
|
+
path: str | None = None,
|
|
100
|
+
json: Any = None,
|
|
101
|
+
params: dict[str, Any] | None = None,
|
|
102
|
+
) -> Any:
|
|
103
|
+
return await self._http.post(
|
|
104
|
+
path or self._path, json=json, params=params
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def _patch(
|
|
108
|
+
self,
|
|
109
|
+
identifier: str,
|
|
110
|
+
json: Any = None,
|
|
111
|
+
) -> Any:
|
|
112
|
+
return await self._http.patch(
|
|
113
|
+
f"{self._path}/{identifier}", json=json
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -- helpers -----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_paginated(data: Any) -> PaginatedResponse:
|
|
121
|
+
"""Parse a paginated API response into a PaginatedResponse."""
|
|
122
|
+
if isinstance(data, dict):
|
|
123
|
+
return PaginatedResponse(
|
|
124
|
+
items=data.get("items", data.get("data", [])),
|
|
125
|
+
total=data.get("total", 0),
|
|
126
|
+
offset=data.get("offset", 0),
|
|
127
|
+
limit=data.get("limit", 100),
|
|
128
|
+
)
|
|
129
|
+
if isinstance(data, list):
|
|
130
|
+
return PaginatedResponse(
|
|
131
|
+
items=data, total=len(data), offset=0, limit=len(data)
|
|
132
|
+
)
|
|
133
|
+
return PaginatedResponse()
|