Skip to content

Commit 0210469

Browse files
committed
feat: add bulk domain lookups
1 parent 0234a70 commit 0210469

File tree

8 files changed

+404
-11
lines changed

8 files changed

+404
-11
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ print(entity.autnums[0].handle) # "AS15169"
4646
api.close()
4747
```
4848

49+
## Bulk domain lookups
50+
51+
Look up multiple domains in a single request (Pro and Business plans). Up to 10 domains per call, with concurrent upstream fetches:
52+
53+
```python
54+
result = api.bulk_domains(["google.com", "github.com", "invalid..com"], follow=True)
55+
56+
print(result.summary) # total=3, successful=2, failed=1
57+
58+
for r in result.results:
59+
if r.status == "success":
60+
print(f"{r.data.domain}: {r.data.registrar.name}")
61+
else:
62+
print(f"{r.domain}: {r.error}")
63+
```
64+
65+
Each domain counts as one request toward your monthly quota. Starter plans receive a `SubscriptionRequiredError` (403).
66+
4967
## Registrar follow-through
5068

5169
For thin registries like `.com` and `.net`, the registry only returns basic registrar info. Use `follow=True` to follow the registrar's RDAP link and get richer contact data:

examples/bulk_lookup.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
"""Look up multiple domains in a loop."""
1+
"""Look up multiple domains in a single bulk request (Pro/Business plans)."""
22

3-
from rdapapi import NotFoundError, RdapApi
3+
from rdapapi import RdapApi
44

55
api = RdapApi("your-api-key")
66

7-
domains = ["google.com", "github.com", "cloudflare.com", "nonexistent.example"]
7+
# Bulk lookup — up to 10 domains in one request
8+
result = api.bulk_domains(
9+
["google.com", "github.com", "invalid..com"],
10+
follow=True,
11+
)
812

9-
for name in domains:
10-
try:
11-
result = api.domain(name)
12-
print(f"{result.domain}: registrar={result.registrar.name}, expires={result.dates.expires}")
13-
except NotFoundError:
14-
print(f"{name}: not found")
13+
print(f"Total: {result.summary.total}, OK: {result.summary.successful}, Failed: {result.summary.failed}")
14+
15+
for r in result.results:
16+
if r.status == "success":
17+
print(f" {r.data.domain}: registrar={r.data.registrar.name}, expires={r.data.dates.expires}")
18+
else:
19+
print(f" {r.domain}: {r.error}{r.message}")
1520

1621
api.close()

src/rdapapi/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
)
1414
from .models import (
1515
AsnResponse,
16+
BulkDomainResponse,
17+
BulkDomainResult,
18+
BulkDomainSummary,
1619
Contact,
1720
Dates,
1821
DomainResponse,
@@ -43,6 +46,9 @@
4346
"UpstreamError",
4447
# Models
4548
"AsnResponse",
49+
"BulkDomainResponse",
50+
"BulkDomainResult",
51+
"BulkDomainSummary",
4652
"Contact",
4753
"Dates",
4854
"DomainResponse",

src/rdapapi/client.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Dict, Optional, Union
5+
from typing import Any, Dict, List, Optional, Union
66

77
import httpx
88

@@ -18,6 +18,7 @@
1818
)
1919
from .models import (
2020
AsnResponse,
21+
BulkDomainResponse,
2122
DomainResponse,
2223
EntityResponse,
2324
IpResponse,
@@ -62,6 +63,14 @@ def _raise_for_status(response: httpx.Response) -> None:
6263
raise exc_class(message, **kwargs)
6364

6465

66+
def _parse_bulk_response(data: dict) -> BulkDomainResponse:
67+
"""Parse a bulk domain response, merging meta into each successful result's data."""
68+
for result in data.get("results", []):
69+
if result.get("status") == "success" and "data" in result and "meta" in result:
70+
result["data"]["meta"] = result.pop("meta")
71+
return BulkDomainResponse.model_validate(data)
72+
73+
6574
class RdapApi:
6675
"""Synchronous client for the RDAP API.
6776
@@ -102,6 +111,15 @@ def _request(
102111
_raise_for_status(response)
103112
return response.json()
104113

114+
def _post(
115+
self,
116+
path: str,
117+
body: Dict[str, Any],
118+
) -> dict:
119+
response = self._client.post(path, json=body)
120+
_raise_for_status(response)
121+
return response.json()
122+
105123
def domain(self, name: str, *, follow: bool = False) -> DomainResponse:
106124
"""Look up RDAP registration data for a domain name."""
107125
params = {"follow": "true"} if follow else None
@@ -132,6 +150,23 @@ def entity(self, handle: str) -> EntityResponse:
132150
data = self._request(f"/entity/{handle}")
133151
return EntityResponse.model_validate(data)
134152

153+
def bulk_domains(
154+
self,
155+
domains: List[str],
156+
*,
157+
follow: bool = False,
158+
) -> BulkDomainResponse:
159+
"""Look up multiple domains in a single request.
160+
161+
Sends up to 10 domains concurrently. Requires a Pro or Business plan.
162+
Each domain counts as one request toward your quota.
163+
"""
164+
body: Dict[str, Any] = {"domains": domains}
165+
if follow:
166+
body["follow"] = True
167+
data = self._post("/domains/bulk", body)
168+
return _parse_bulk_response(data)
169+
135170
def close(self) -> None:
136171
"""Close the underlying HTTP client."""
137172
self._client.close()
@@ -183,6 +218,15 @@ async def _request(
183218
_raise_for_status(response)
184219
return response.json()
185220

221+
async def _post(
222+
self,
223+
path: str,
224+
body: Dict[str, Any],
225+
) -> dict:
226+
response = await self._client.post(path, json=body)
227+
_raise_for_status(response)
228+
return response.json()
229+
186230
async def domain(self, name: str, *, follow: bool = False) -> DomainResponse:
187231
"""Look up RDAP registration data for a domain name."""
188232
params = {"follow": "true"} if follow else None
@@ -213,6 +257,23 @@ async def entity(self, handle: str) -> EntityResponse:
213257
data = await self._request(f"/entity/{handle}")
214258
return EntityResponse.model_validate(data)
215259

260+
async def bulk_domains(
261+
self,
262+
domains: List[str],
263+
*,
264+
follow: bool = False,
265+
) -> BulkDomainResponse:
266+
"""Look up multiple domains in a single request.
267+
268+
Sends up to 10 domains concurrently. Requires a Pro or Business plan.
269+
Each domain counts as one request toward your quota.
270+
"""
271+
body: Dict[str, Any] = {"domains": domains}
272+
if follow:
273+
body["follow"] = True
274+
data = await self._post("/domains/bulk", body)
275+
return _parse_bulk_response(data)
276+
216277
async def close(self) -> None:
217278
"""Close the underlying HTTP client."""
218279
await self._client.aclose()

src/rdapapi/models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
__all__ = [
1010
"AsnResponse",
11+
"BulkDomainResponse",
12+
"BulkDomainResult",
13+
"BulkDomainSummary",
1114
"Contact",
1215
"Dates",
1316
"DomainResponse",
@@ -183,6 +186,36 @@ class EntityNetwork(BaseModel):
183186
cidr: List[str] = Field(default_factory=list)
184187

185188

189+
class BulkDomainResult(BaseModel):
190+
"""A single result within a bulk domain lookup response.
191+
192+
When ``status`` is ``"success"``, ``data`` contains a full
193+
:class:`DomainResponse`. When ``status`` is ``"error"``,
194+
``error`` and ``message`` describe the failure.
195+
"""
196+
197+
domain: str
198+
status: str
199+
data: Optional[DomainResponse] = None
200+
error: Optional[str] = None
201+
message: Optional[str] = None
202+
203+
204+
class BulkDomainSummary(BaseModel):
205+
"""Summary counts for a bulk domain lookup."""
206+
207+
total: int
208+
successful: int
209+
failed: int
210+
211+
212+
class BulkDomainResponse(BaseModel):
213+
"""Response from a bulk domain lookup."""
214+
215+
results: List[BulkDomainResult]
216+
summary: BulkDomainSummary
217+
218+
186219
class EntityResponse(BaseModel):
187220
"""Response from an entity lookup."""
188221

tests/test_async.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import pytest
55
import respx
66

7-
from rdapapi import AsyncRdapApi, AuthenticationError, NotFoundError, RateLimitError
7+
from rdapapi import AsyncRdapApi, AuthenticationError, NotFoundError, RateLimitError, SubscriptionRequiredError
8+
from rdapapi.models import BulkDomainResponse
89

910
BASE_URL = "https://rdapapi.io/api/v1"
1011

@@ -123,3 +124,69 @@ async def test_async_rate_limit_error():
123124
await api.domain("test.com")
124125

125126
assert exc_info.value.retry_after == 30
127+
128+
129+
# === Async Bulk Domain Lookups ===
130+
131+
BULK_RESPONSE = {
132+
"results": [
133+
{
134+
"domain": "google.com",
135+
"status": "success",
136+
"data": {
137+
"domain": "google.com",
138+
"unicode_name": None,
139+
"handle": "2138514_DOMAIN_COM-VRSN",
140+
"status": ["client delete prohibited"],
141+
"registrar": {
142+
"name": "MarkMonitor Inc.",
143+
"iana_id": "292",
144+
"abuse_email": None,
145+
"abuse_phone": None,
146+
"url": None,
147+
},
148+
"dates": {"registered": "1997-09-15T04:00:00Z", "expires": "2028-09-14T04:00:00Z", "updated": None},
149+
"nameservers": ["ns1.google.com"],
150+
"dnssec": False,
151+
"entities": {},
152+
},
153+
"meta": {
154+
"rdap_server": "https://rdap.verisign.com/com/v1/",
155+
"raw_rdap_url": "https://rdap.verisign.com/com/v1/domain/google.com",
156+
"cached": False,
157+
"cache_expires": "2026-02-25T15:30:00Z",
158+
},
159+
},
160+
],
161+
"summary": {"total": 1, "successful": 1, "failed": 0},
162+
}
163+
164+
165+
@pytest.mark.asyncio
166+
@respx.mock
167+
async def test_async_bulk_domains_lookup():
168+
respx.post(f"{BASE_URL}/domains/bulk").mock(return_value=httpx.Response(200, json=BULK_RESPONSE))
169+
170+
async with AsyncRdapApi("test-key", base_url=BASE_URL) as api:
171+
result = await api.bulk_domains(["google.com"])
172+
173+
assert isinstance(result, BulkDomainResponse)
174+
assert result.summary.successful == 1
175+
assert result.results[0].data is not None
176+
assert result.results[0].data.domain == "google.com"
177+
178+
179+
@pytest.mark.asyncio
180+
@respx.mock
181+
async def test_async_bulk_domains_plan_upgrade_required():
182+
respx.post(f"{BASE_URL}/domains/bulk").mock(
183+
return_value=httpx.Response(
184+
403, json={"error": "plan_upgrade_required", "message": "Bulk lookups require a Pro or Business plan."}
185+
)
186+
)
187+
188+
async with AsyncRdapApi("test-key", base_url=BASE_URL) as api:
189+
with pytest.raises(SubscriptionRequiredError) as exc_info:
190+
await api.bulk_domains(["google.com"])
191+
192+
assert exc_info.value.error == "plan_upgrade_required"

0 commit comments

Comments
 (0)