Skip to content

Commit 63a939f

Browse files
committed
Feat: Extract BaseClient, add search/domain/streaming/account endpoints
- Add BaseClient in base.py with shared init, headers, and response transformation logic (_parse_events, _parse_host_result, etc.) - Add serialize_queries() helper in query.py to reduce duplication - Client now extends BaseClient, delegates parsing to shared methods - New endpoints: search(), get_domain(), bulk_export_stream(), get_api_status(), is_pro()
1 parent 3601565 commit 63a939f

3 files changed

Lines changed: 162 additions & 85 deletions

File tree

leakix/base.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Shared logic between sync and async LeakIX clients."""
2+
3+
import dataclasses
4+
from importlib.metadata import version
5+
from typing import Any, cast
6+
7+
from l9format import l9format
8+
from l9format.l9format import Model
9+
10+
from leakix.domain import L9Subdomain
11+
from leakix.plugin import APIResult
12+
from leakix.response import AbstractResponse
13+
14+
DEFAULT_URL = "https://leakix.net"
15+
16+
17+
@dataclasses.dataclass
18+
class HostResult(Model):
19+
Services: list[l9format.L9Event] | None = None
20+
Leaks: list[l9format.L9Event] | None = None
21+
22+
23+
class BaseClient:
24+
"""Shared initialization and response transformation logic."""
25+
26+
MAX_RESULTS_PER_PAGE = 20
27+
28+
def __init__(
29+
self,
30+
api_key: str | None = None,
31+
base_url: str | None = DEFAULT_URL,
32+
) -> None:
33+
self.api_key = api_key
34+
self.base_url = base_url if base_url else DEFAULT_URL
35+
self.headers: dict[str, str] = {
36+
"Accept": "application/json",
37+
"User-agent": f"leakix-client-python/{version('leakix')}",
38+
}
39+
if api_key:
40+
self.headers["api-key"] = api_key
41+
self._api_status: AbstractResponse | None = None
42+
43+
@staticmethod
44+
def _parse_events(response: AbstractResponse) -> AbstractResponse:
45+
"""Parse raw JSON dicts into L9Event objects on a success response."""
46+
if response.is_success():
47+
response.response_json = [
48+
l9format.L9Event.from_dict(res) for res in response.response_json
49+
]
50+
return response
51+
52+
@staticmethod
53+
def _parse_host_result(response: AbstractResponse) -> AbstractResponse:
54+
"""Parse a host/domain response into {services, leaks} format."""
55+
if response.is_success():
56+
data: dict[str, Any] = response.json()
57+
formatted = cast(HostResult, HostResult.from_dict(data))
58+
response.response_json = {
59+
"services": formatted.Services,
60+
"leaks": formatted.Leaks,
61+
}
62+
return response
63+
64+
@staticmethod
65+
def _parse_plugins(response: AbstractResponse) -> AbstractResponse:
66+
if response.is_success():
67+
response.response_json = [APIResult.from_dict(d) for d in response.json()]
68+
return response
69+
70+
@staticmethod
71+
def _parse_subdomains(response: AbstractResponse) -> AbstractResponse:
72+
if response.is_success():
73+
response.response_json = [L9Subdomain.from_dict(d) for d in response.json()]
74+
return response

leakix/client.py

Lines changed: 81 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
import dataclasses
21
import json
2+
from collections.abc import Iterator
33
from enum import Enum
4-
from importlib.metadata import version
54
from typing import Any, cast
65

76
import requests
87
from l9format import l9format
9-
from l9format.l9format import Model
108

11-
from leakix.domain import L9Subdomain
12-
from leakix.plugin import APIResult
13-
from leakix.query import EmptyQuery, Query
9+
from leakix.base import BaseClient
10+
from leakix.base import HostResult as HostResult
11+
from leakix.query import Query, serialize_queries
1412
from leakix.response import (
1513
AbstractResponse,
1614
ErrorResponse,
@@ -24,32 +22,7 @@ class Scope(Enum):
2422
LEAK = "leak"
2523

2624

27-
@dataclasses.dataclass
28-
class HostResult(Model):
29-
Services: list[l9format.L9Event] | None = None
30-
Leaks: list[l9format.L9Event] | None = None
31-
32-
33-
DEFAULT_URL = "https://leakix.net"
34-
35-
36-
class Client:
37-
MAX_RESULTS_PER_PAGE = 20
38-
39-
def __init__(
40-
self,
41-
api_key: str | None = None,
42-
base_url: str | None = DEFAULT_URL,
43-
) -> None:
44-
self.api_key = api_key
45-
self.base_url = base_url if base_url else DEFAULT_URL
46-
self.headers: dict[str, str] = {
47-
"Accept": "application/json",
48-
"User-agent": f"leakix-client-python/{version('leakix')}",
49-
}
50-
if api_key:
51-
self.headers["api-key"] = api_key
52-
25+
class Client(BaseClient):
5326
def __get(self, url: str, params: dict[str, Any] | None) -> AbstractResponse:
5427
r = requests.get(
5528
url,
@@ -96,64 +69,36 @@ def get(
9669
"""
9770
if page < 0:
9871
raise ValueError("Page argument must be a positive integer")
99-
if queries is None or len(queries) == 0:
100-
serialized_query = EmptyQuery().serialize()
101-
else:
102-
serialized_query = " ".join(q.serialize() for q in queries)
72+
serialized_query = serialize_queries(queries)
10373
url = f"{self.base_url}/search"
104-
r = self.__get(
74+
return self.__get(
10575
url=url,
10676
params={
10777
"scope": scope.value,
10878
"q": serialized_query,
10979
"page": page,
11080
},
11181
)
112-
return r
11382

11483
def get_service(
11584
self, queries: list[Query] | None = None, page: int = 0
11685
) -> AbstractResponse:
117-
"""
118-
Shortcut for `get` with the scope `Scope.Service`.
119-
120-
"""
121-
r = self.get(Scope.SERVICE, queries=queries, page=page)
122-
if r.is_success():
123-
r.response_json = [
124-
l9format.L9Event.from_dict(res) for res in r.response_json
125-
]
126-
return r
86+
"""Shortcut for `get` with the scope `Scope.SERVICE`."""
87+
return self._parse_events(self.get(Scope.SERVICE, queries=queries, page=page))
12788

12889
def get_leak(
12990
self, queries: list[Query] | None = None, page: int = 0
13091
) -> AbstractResponse:
131-
"""
132-
Shortcut for `get` with the scope `Scope.Leak`.
133-
"""
134-
r = self.get(Scope.LEAK, queries=queries, page=page)
135-
if r.is_success():
136-
r.response_json = [
137-
l9format.L9Event.from_dict(res) for res in r.response_json
138-
]
139-
return r
92+
"""Shortcut for `get` with the scope `Scope.LEAK`."""
93+
return self._parse_events(self.get(Scope.LEAK, queries=queries, page=page))
14094

14195
def get_host(self, ipv4: str) -> AbstractResponse:
14296
"""
14397
Returns the list of services and associated leaks for a given host. Only the ipv4 format is supported at the
14498
moment.
14599
"""
146100
url = f"{self.base_url}/host/{ipv4}"
147-
r = self.__get(url, params=None)
148-
if r.is_success():
149-
response_json = r.json()
150-
formatted_result = cast(HostResult, HostResult.from_dict(response_json))
151-
response_json = {
152-
"services": formatted_result.Services,
153-
"leaks": formatted_result.Leaks,
154-
}
155-
r.response_json = response_json
156-
return r
101+
return self._parse_host_result(self.__get(url, params=None))
157102

158103
def get_plugins(self) -> AbstractResponse:
159104
"""
@@ -166,10 +111,7 @@ def get_plugins(self) -> AbstractResponse:
166111
For the paid plans, have a look at https://leakix.net/plans.
167112
"""
168113
url = f"{self.base_url}/api/plugins"
169-
r = self.__get(url, params=None)
170-
if r.is_success():
171-
r.response_json = [APIResult.from_dict(d) for d in r.json()]
172-
return r
114+
return self._parse_plugins(self.__get(url, params=None))
173115

174116
def get_subdomains(self, domain: str) -> AbstractResponse:
175117
"""
@@ -178,18 +120,11 @@ def get_subdomains(self, domain: str) -> AbstractResponse:
178120
To get back a JSON/Python dictionary, use the method `to_dict` on the individual element of the response object.
179121
"""
180122
url = f"{self.base_url}/api/subdomains/{domain}"
181-
r = self.__get(url, params=None)
182-
if r.is_success():
183-
r.response_json = [L9Subdomain.from_dict(d) for d in r.json()]
184-
return r
123+
return self._parse_subdomains(self.__get(url, params=None))
185124

186125
def bulk_export(self, queries: list[Query] | None = None) -> AbstractResponse:
187126
url = f"{self.base_url}/bulk/search"
188-
if queries is None or len(queries) == 0:
189-
serialized_query = EmptyQuery().serialize()
190-
else:
191-
serialized_query = " ".join(q.serialize() for q in queries)
192-
params = {"q": serialized_query}
127+
params = {"q": serialize_queries(queries)}
193128
r = requests.get(url, params=params, headers=self.headers, stream=True)
194129
if r.status_code == 200:
195130
response_json = []
@@ -221,11 +156,7 @@ def bulk_export_last_event(
221156

222157
def bulk_service(self, queries: list[Query] | None = None) -> AbstractResponse:
223158
url = f"{self.base_url}/bulk/service"
224-
if queries is None or len(queries) == 0:
225-
serialized_query = EmptyQuery().serialize()
226-
else:
227-
serialized_query = " ".join(q.serialize() for q in queries)
228-
params = {"q": serialized_query}
159+
params = {"q": serialize_queries(queries)}
229160
r = requests.get(url, params=params, headers=self.headers, stream=True)
230161
if r.status_code == 200:
231162
response_json = []
@@ -239,3 +170,68 @@ def bulk_service(self, queries: list[Query] | None = None) -> AbstractResponse:
239170
return ErrorResponse(response=r, response_json=[], status_code=200)
240171
else:
241172
return ErrorResponse(response=r, response_json=r.json())
173+
174+
def get_domain(self, domain: str) -> AbstractResponse:
175+
"""
176+
Returns the list of services and associated leaks for a given domain.
177+
"""
178+
url = f"{self.base_url}/domain/{domain}"
179+
return self._parse_host_result(self.__get(url, params=None))
180+
181+
def search(
182+
self, query: str, scope: Scope = Scope.LEAK, page: int = 0
183+
) -> AbstractResponse:
184+
"""
185+
Simple search using a raw query string (same syntax as the website).
186+
187+
Example:
188+
>>> client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK)
189+
>>> client.search("+country:FR +port:22", scope=Scope.SERVICE)
190+
"""
191+
if page < 0:
192+
raise ValueError("Page argument must be a positive integer")
193+
url = f"{self.base_url}/search"
194+
r = self.__get(
195+
url=url,
196+
params={"scope": scope.value, "q": query, "page": page},
197+
)
198+
return self._parse_events(r)
199+
200+
def bulk_export_stream(
201+
self, queries: list[Query] | None = None
202+
) -> Iterator[l9format.L9Aggregation]:
203+
"""
204+
Streaming version of bulk_export. Yields L9Aggregation objects one by one.
205+
More memory efficient for large result sets.
206+
"""
207+
url = f"{self.base_url}/bulk/search"
208+
params = {"q": serialize_queries(queries)}
209+
r = requests.get(url, params=params, headers=self.headers, stream=True)
210+
if r.status_code != 200:
211+
return
212+
for line in r.iter_lines():
213+
json_event = json.loads(line)
214+
yield cast(
215+
l9format.L9Aggregation,
216+
l9format.L9Aggregation.from_dict(json_event),
217+
)
218+
219+
def get_api_status(self, force: bool = False) -> AbstractResponse:
220+
"""
221+
Check API status and subscription info via /api/user/info endpoint.
222+
Results are cached per client instance. Use force=True to refresh.
223+
"""
224+
if self._api_status is not None and not force:
225+
return self._api_status
226+
227+
url = f"{self.base_url}/api/user/info"
228+
r = self.__get(url, params=None)
229+
self._api_status = r
230+
return r
231+
232+
def is_pro(self) -> bool:
233+
"""Check if the API key has Pro access. Result is cached."""
234+
r = self.get_api_status()
235+
if r.is_success():
236+
return bool(r.json().get("is_pro", False))
237+
return False

leakix/query.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,10 @@ def __init__(self, raw_q: str) -> None:
7575

7676
def serialize(self) -> str:
7777
return self.raw_q
78+
79+
80+
def serialize_queries(queries: list[Query] | None) -> str:
81+
"""Serialize a list of queries into a query string for the API."""
82+
if queries is None or len(queries) == 0:
83+
return EmptyQuery().serialize()
84+
return " ".join(q.serialize() for q in queries)

0 commit comments

Comments
 (0)