Skip to content

Commit 0ec6884

Browse files
committed
Feat: Add AsyncClient with httpx for async API access
1 parent 91c65ab commit 0ec6884

3 files changed

Lines changed: 196 additions & 2 deletions

File tree

leakix/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from importlib.metadata import version
22

3+
from leakix.async_client import AsyncClient as AsyncClient
34
from leakix.base import HostResult as HostResult
45
from leakix.client import Client as Client
56
from leakix.client import Scope as Scope
@@ -71,6 +72,7 @@
7172

7273
__all__ = [
7374
"__version__",
75+
"AsyncClient",
7476
"Client",
7577
"HostResult",
7678
"L9Subdomain",

leakix/async_client.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Async LeakIX API client using httpx."""
2+
3+
import json
4+
from collections.abc import AsyncIterator
5+
from typing import Any, cast
6+
7+
import httpx
8+
from l9format import l9format
9+
10+
from leakix.base import DEFAULT_URL, BaseClient
11+
from leakix.client import Scope
12+
from leakix.query import AbstractQuery, serialize_queries
13+
from leakix.response import (
14+
AbstractResponse,
15+
ErrorResponse,
16+
RateLimitResponse,
17+
SuccessResponse,
18+
)
19+
20+
DEFAULT_TIMEOUT = 30.0
21+
22+
23+
class AsyncClient(BaseClient):
24+
"""Async client for the LeakIX API.
25+
26+
Mirrors the sync Client API but uses httpx for async operations.
27+
All methods return AbstractResponse for consistency with the sync client.
28+
"""
29+
30+
def __init__(
31+
self,
32+
api_key: str | None = None,
33+
base_url: str | None = DEFAULT_URL,
34+
timeout: float = DEFAULT_TIMEOUT,
35+
) -> None:
36+
super().__init__(api_key=api_key, base_url=base_url)
37+
self.timeout = timeout
38+
self._client: httpx.AsyncClient | None = None
39+
40+
async def _get_client(self) -> httpx.AsyncClient:
41+
"""Get or create the HTTP client."""
42+
if self._client is None or self._client.is_closed:
43+
self._client = httpx.AsyncClient(
44+
base_url=self.base_url,
45+
headers=self.headers,
46+
timeout=self.timeout,
47+
)
48+
return self._client
49+
50+
async def close(self) -> None:
51+
"""Close the HTTP client."""
52+
if self._client is not None and not self._client.is_closed:
53+
await self._client.aclose()
54+
self._client = None
55+
56+
async def __aenter__(self) -> "AsyncClient":
57+
return self
58+
59+
async def __aexit__(self, *args: Any) -> None:
60+
await self.close()
61+
62+
async def __get(
63+
self, path: str, params: dict[str, Any] | None = None
64+
) -> AbstractResponse:
65+
"""Make a GET request and return an AbstractResponse."""
66+
client = await self._get_client()
67+
r = await client.get(path, params=params)
68+
if r.status_code == 200:
69+
response_json = r.json() if r.content else []
70+
return SuccessResponse(response=r, response_json=response_json)
71+
elif r.status_code == 429:
72+
return RateLimitResponse(response=r)
73+
elif r.status_code == 204:
74+
return SuccessResponse(response=r, response_json=[])
75+
else:
76+
return ErrorResponse(response=r, response_json=r.json())
77+
78+
async def get(
79+
self,
80+
scope: Scope,
81+
queries: list[AbstractQuery] | None = None,
82+
page: int = 0,
83+
) -> AbstractResponse:
84+
"""Search LeakIX for services or leaks."""
85+
if page < 0:
86+
raise ValueError("Page argument must be a positive integer")
87+
serialized_query = serialize_queries(queries)
88+
return await self.__get(
89+
"/search",
90+
params={"scope": scope.value, "q": serialized_query, "page": page},
91+
)
92+
93+
async def get_service(
94+
self, queries: list[AbstractQuery] | None = None, page: int = 0
95+
) -> AbstractResponse:
96+
"""Shortcut for get with scope=Scope.SERVICE."""
97+
return self._parse_events(
98+
await self.get(Scope.SERVICE, queries=queries, page=page)
99+
)
100+
101+
async def get_leak(
102+
self, queries: list[AbstractQuery] | None = None, page: int = 0
103+
) -> AbstractResponse:
104+
"""Shortcut for get with scope=Scope.LEAK."""
105+
return self._parse_events(
106+
await self.get(Scope.LEAK, queries=queries, page=page)
107+
)
108+
109+
async def search(
110+
self, query: str, scope: Scope = Scope.LEAK, page: int = 0
111+
) -> AbstractResponse:
112+
"""
113+
Simple search using a raw query string (same syntax as the website).
114+
115+
Example:
116+
>>> await client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK)
117+
"""
118+
if page < 0:
119+
raise ValueError("Page argument must be a positive integer")
120+
r = await self.__get(
121+
"/search",
122+
params={"scope": scope.value, "q": query, "page": page},
123+
)
124+
return self._parse_events(r)
125+
126+
async def get_host(self, ipv4: str) -> AbstractResponse:
127+
"""Returns the list of services and associated leaks for a given host."""
128+
return self._parse_host_result(await self.__get(f"/host/{ipv4}"))
129+
130+
async def get_domain(self, domain: str) -> AbstractResponse:
131+
"""Returns the list of services and associated leaks for a given domain."""
132+
return self._parse_host_result(await self.__get(f"/domain/{domain}"))
133+
134+
async def get_plugins(self) -> AbstractResponse:
135+
"""Returns the list of plugins the authenticated user has access to."""
136+
return self._parse_plugins(await self.__get("/api/plugins"))
137+
138+
async def get_subdomains(self, domain: str) -> AbstractResponse:
139+
"""Returns the list of subdomains for a given domain."""
140+
return self._parse_subdomains(await self.__get(f"/api/subdomains/{domain}"))
141+
142+
async def bulk_export(
143+
self, queries: list[AbstractQuery] | None = None
144+
) -> AbstractResponse:
145+
"""Bulk export leaks (Pro API feature)."""
146+
serialized_query = serialize_queries(queries)
147+
client = await self._get_client()
148+
async with client.stream(
149+
"GET", "/bulk/search", params={"q": serialized_query}
150+
) as r:
151+
if r.status_code == 200:
152+
response_json = []
153+
async for line in r.aiter_lines():
154+
if line:
155+
json_event = json.loads(line)
156+
response_json.append(
157+
l9format.L9Aggregation.from_dict(json_event)
158+
)
159+
return SuccessResponse(response=r, response_json=response_json)
160+
elif r.status_code == 429:
161+
return RateLimitResponse(response=r)
162+
elif r.status_code == 204:
163+
return SuccessResponse(response=r, response_json=[])
164+
else:
165+
await r.aread()
166+
return ErrorResponse(response=r, response_json=r.json())
167+
168+
async def bulk_export_stream(
169+
self, queries: list[AbstractQuery] | None = None
170+
) -> AsyncIterator[l9format.L9Aggregation]:
171+
"""
172+
Streaming version of bulk_export. Yields L9Aggregation objects one by one.
173+
More memory efficient for large result sets.
174+
"""
175+
serialized_query = serialize_queries(queries)
176+
client = await self._get_client()
177+
async with client.stream(
178+
"GET", "/bulk/search", params={"q": serialized_query}
179+
) as r:
180+
if r.status_code != 200:
181+
return
182+
async for line in r.aiter_lines():
183+
if line:
184+
json_event = json.loads(line)
185+
yield cast(
186+
l9format.L9Aggregation,
187+
l9format.L9Aggregation.from_dict(json_event),
188+
)

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
[project]
22
name = "leakix"
3-
version = "0.1.10"
3+
version = "0.2.0"
44
description = "Official python client for LeakIX (https://leakix.net)"
5-
authors = [{ name = "Danny Willems", email = "danny@leakix.net" }]
5+
authors = [
6+
{ name = "Danny Willems", email = "danny@leakix.net" },
7+
{ name = "Valentin Lobstein", email = "valentin@leakix.net" },
8+
]
69
requires-python = ">=3.11"
710
dependencies = [
811
"requests",
12+
"httpx>=0.28.0",
913
"l9format==2.0.0",
1014
"fire>=0.5,<0.8",
1115
]

0 commit comments

Comments
 (0)