diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7822c40 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + +jobs: + unit-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.9", "3.12" ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + run: | + python -m pip install --upgrade pip + python -m pip install . + - name: Run unit tests + run: python -m unittest discover -s tests -p "test_*_unit.py" -v diff --git a/bgpkit/__init__.py b/bgpkit/__init__.py index 73f3bd0..cccda11 100644 --- a/bgpkit/__init__.py +++ b/bgpkit/__init__.py @@ -1,4 +1,5 @@ from .bgpkit_parser import Filter, Parser, RouteElem, RouteParser +from ._utils import BGPKITApiError from .bgpkit_broker import Broker, BrokerItem, CollectorItem, PeerItem from .bgpkit_roas import Roas, RoasItem from .bgpkit_ip import IpLookup, IpInfo diff --git a/bgpkit/_utils.py b/bgpkit/_utils.py new file mode 100644 index 0000000..f53611b --- /dev/null +++ b/bgpkit/_utils.py @@ -0,0 +1,39 @@ +"""Shared helpers for the BGPKIT API clients.""" + +from dataclasses import fields +from typing import Optional + +import requests + +DEFAULT_TIMEOUT = 30.0 + + +class BGPKITApiError(RuntimeError): + """Raised when an API request fails or returns an error response.""" + + +def from_item(cls, item: dict): + """Build a dataclass from an API item, ignoring unknown fields.""" + names = {field.name for field in fields(cls)} + return cls(**{key: value for key, value in item.items() if key in names}) + + +def request_json( + url: str, + params: Optional[dict] = None, + verify: bool = True, + timeout: float = DEFAULT_TIMEOUT, +) -> dict: + """GET a JSON API endpoint, raising BGPKITApiError on failures.""" + try: + response = requests.get(url, params=params or {}, verify=verify, timeout=timeout) + except requests.RequestException as error: + raise BGPKITApiError(f"GET {url} failed: {error}") from error + if response.status_code >= 400: + raise BGPKITApiError( + f"GET {url} failed with HTTP {response.status_code}: {response.text[:200]}" + ) + try: + return response.json() + except ValueError as error: + raise BGPKITApiError(f"GET {url} returned a non-JSON response") from error diff --git a/bgpkit/bgpkit_asn.py b/bgpkit/bgpkit_asn.py index 94ffd93..d972557 100644 --- a/bgpkit/bgpkit_asn.py +++ b/bgpkit/bgpkit_asn.py @@ -1,13 +1,17 @@ from dataclasses import dataclass from typing import List, Optional -import requests +from ._utils import DEFAULT_TIMEOUT, from_item, request_json @dataclass class Pagination: - next: Optional[str] = None - previous: Optional[str] = None + page: int = 0 + page_size: int = 0 + total_pages: int = 0 + total_count: int = 0 + has_next: bool = False + has_prev: bool = False @dataclass @@ -15,10 +19,13 @@ class AsnInfo: asn: int name: str country: str = "" + country_name: str = "" org_name: str = "" org_id: str = "" - description: str = "" - website: str = "" + as2org: Optional[dict] = None + hegemony: Optional[dict] = None + peeringdb: Optional[dict] = None + population: Optional[dict] = None @dataclass @@ -34,17 +41,34 @@ class AsnLookupResult: class AsnLookup: """BGPKIT ASN information lookup (v3/utils/asn).""" - def __init__(self, api_url: str = "https://api.bgpkit.com/v3/utils"): + def __init__( + self, + api_url: str = "https://api.bgpkit.com/v3/utils", + timeout: float = DEFAULT_TIMEOUT, + ): self.base_url = api_url.rstrip("/") + self.timeout = timeout def query( self, asn: str = None, country: str = None, search: str = None, - page: int = 1, + page: int = 0, page_size: int = 100, ) -> AsnLookupResult: + """Look up ASN information. + + Args: + asn: AS number, or a comma-separated list of AS numbers. + country: Filter by ISO country code. + search: Free-text search over ASN names and organizations. + page: Page number to start from (0-indexed). + page_size: Results per page. + + Returns: + AsnLookupResult with the parsed ASN entries and pagination info. + """ params = {} if asn: params["asn"] = asn @@ -52,19 +76,41 @@ def query( params["country"] = country if search: params["search"] = search - if page: + if page is not None: params["page"] = page if page_size: params["page_size"] = min(page_size, 10000) - res = requests.get(f"{self.base_url}/asn", params=params).json() - data = [AsnInfo(**item) for item in res.get("data", [])] - pag = res.get("pagination", {}) + response = request_json(f"{self.base_url}/asn", params=params, timeout=self.timeout) + + data = [] + for item in response.get("data", []): + as2org = item.get("as2org") if isinstance(item.get("as2org"), dict) else {} + data.append( + AsnInfo( + asn=item.get("asn"), + name=item.get("name", ""), + country=item.get("country", ""), + country_name=item.get("country_name", ""), + org_name=as2org.get("org_name", ""), + org_id=as2org.get("org_id", ""), + as2org=as2org or None, + hegemony=item.get("hegemony"), + peeringdb=item.get("peeringdb"), + population=item.get("population"), + ) + ) + + pagination_data = response.get("pagination") + pagination = ( + from_item(Pagination, pagination_data) if isinstance(pagination_data, dict) else None + ) + return AsnLookupResult( data=data, - count=res.get("count", 0), - page=res.get("page", page), - page_size=res.get("page_size", page_size), - updated_at=res.get("updatedAt", ""), - pagination=Pagination(**pag) if pag else None, + count=response.get("count", 0), + page=response.get("page", page), + page_size=response.get("page_size", page_size), + updated_at=response.get("updatedAt", ""), + pagination=pagination, ) diff --git a/bgpkit/bgpkit_broker.py b/bgpkit/bgpkit_broker.py index b69cb93..8520098 100644 --- a/bgpkit/bgpkit_broker.py +++ b/bgpkit/bgpkit_broker.py @@ -1,9 +1,14 @@ -from dataclasses import dataclass, field +import json +import os +from dataclasses import dataclass from typing import List, Optional -import requests as requests import urllib3 +from ._utils import DEFAULT_TIMEOUT, BGPKITApiError, from_item, request_json + +DEFAULT_API_URL = "https://api.bgpkit.com/v3/broker" + def check_type(value: any, ty: type) -> bool: try: @@ -22,31 +27,37 @@ class BrokerItem: url: str rough_size: int exact_size: int + delay: Optional[float] = None # seconds since the latest catalog update; /latest items only @dataclass class PeerItem: + date: str + collector: str ip: str asn: int - collector: str - full_feed: bool = False + num_v4_pfxs: int + num_v6_pfxs: int + num_connected_asns: int + + @property + def full_feed(self) -> bool: + """True for full-table peers (>700k IPv4 or >100k IPv6 prefixes).""" + return self.num_v4_pfxs > 700_000 or self.num_v6_pfxs > 100_000 @dataclass class CollectorItem: - id: str name: str project: str + data_url: str + activated_on: str + deactivated_on: Optional[str] country: str - active: bool = True - latitude: Optional[float] = None - longitude: Optional[float] = None - -@dataclass -class LatestResult: - """Result of the /v3/broker/latest endpoint.""" - items: List[BrokerItem] = field(default_factory=list) + @property + def active(self) -> bool: + return self.deactivated_on is None class Broker: @@ -54,48 +65,69 @@ class Broker: Provides access to MRT data file search, peer information, collector metadata, and latest file discovery. + + The API base URL can be overridden per instance with ``api_url`` or for the + whole process with the ``BGPKIT_BROKER_URL`` environment variable. """ def __init__( self, - api_url: str = "https://api.bgpkit.com/v3/broker", + api_url: Optional[str] = None, page_size: int = 100, verify: bool = True, + timeout: float = DEFAULT_TIMEOUT, ): + if api_url is None: + api_url = os.environ.get("BGPKIT_BROKER_URL") or DEFAULT_API_URL self.base_url = api_url.rstrip("/") self.page_size = int(page_size) self.verify = verify + self.timeout = timeout if not verify: urllib3.disable_warnings() + def _request(self, endpoint: str, params: Optional[dict] = None) -> dict: + """Make a single request and return the parsed JSON body.""" + return request_json( + f"{self.base_url}/{endpoint}", + params=params, + verify=self.verify, + timeout=self.timeout, + ) + def _paginate(self, endpoint: str, params: dict) -> dict: - """Fetch all pages of a paginated broker endpoint.""" - page = 1 - all_data = [] + """Fetch every page of a paginated broker endpoint. + Stops when the reported total is reached, or when a page adds no new + items. The latter also terminates endpoints that ignore pagination + parameters instead of looping on the same full page forever. + """ + page = 1 + all_data: List[dict] = [] + seen = set() + result = {} while True: - params["page"] = page - params["page_size"] = self.page_size - res = requests.get( - f"{self.base_url}/{endpoint}", - params=params, - verify=self.verify, - ).json() - - if isinstance(res, dict): - data = res.get("data", []) - all_data.extend(data) - if len(data) < self.page_size: - break - elif isinstance(res, list): - all_data.extend(res) + page_params = dict(params) + page_params["page"] = page + page_params["page_size"] = self.page_size + + result = self._request(endpoint, page_params) + data = result.get("data", []) + new_items = [] + for item in data: + key = json.dumps(item, sort_keys=True, default=str) + if key not in seen: + seen.add(key) + new_items.append(item) + all_data.extend(new_items) + + total = result.get("total") + if not data or not new_items: break - else: + if total is not None and len(all_data) >= int(total): break - page += 1 - result = res if isinstance(res, dict) else {"data": all_data} result["data"] = all_data return result @@ -123,18 +155,12 @@ def query( params["data_type"] = data_type result = self._paginate("search", params) - return [BrokerItem(**item) for item in result.get("data", [])] + return [from_item(BrokerItem, item) for item in result.get("data", [])] def latest(self) -> List[BrokerItem]: - """Get latest MRT data files across projects.""" - res = requests.get( - f"{self.base_url}/latest", - verify=self.verify, - ).json() - if isinstance(res, list): - return [BrokerItem(**item) for item in res] - data = res.get("data", []) - return [BrokerItem(**item) for item in data] + """Get the latest MRT data file for every collector and data type.""" + result = self._request("latest") + return [from_item(BrokerItem, item) for item in result.get("data", [])] def peers( self, @@ -154,8 +180,8 @@ def peers( if collector: params["collector"] = collector - result = self._paginate("peers", params) - return [PeerItem(**item) for item in result.get("data", [])] + result = self._request("peers", params) + return [from_item(PeerItem, item) for item in result.get("data", [])] def collectors( self, @@ -172,5 +198,5 @@ def collectors( if active is not None: params["active"] = str(active).lower() - result = self._paginate("collectors", params) - return [CollectorItem(**item) for item in result.get("data", [])] + result = self._request("collectors", params) + return [from_item(CollectorItem, item) for item in result.get("data", [])] diff --git a/bgpkit/bgpkit_community.py b/bgpkit/bgpkit_community.py index 05cd89b..d41ddd2 100644 --- a/bgpkit/bgpkit_community.py +++ b/bgpkit/bgpkit_community.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import List, Optional -import requests +from ._utils import DEFAULT_TIMEOUT, from_item, request_json @dataclass @@ -12,6 +12,7 @@ class CommunityEntry: as_name: str = "" country: str = "" source: str = "" + range: Optional[str] = None @dataclass @@ -19,13 +20,20 @@ class CommunitySource: id: str name: str url: str = "" + last_updated: Optional[str] = None + stats: Optional[dict] = None class CommunityLookup: """BGPKIT community lookup (v3/communities).""" - def __init__(self, api_url: str = "https://api.bgpkit.com/v3/communities"): + def __init__( + self, + api_url: str = "https://api.bgpkit.com/v3/communities", + timeout: float = DEFAULT_TIMEOUT, + ): self.base_url = api_url.rstrip("/") + self.timeout = timeout def query( self, @@ -37,6 +45,11 @@ def query( page: int = 0, page_size: int = 10, ) -> List[CommunityEntry]: + """Query BGP communities. + + The API returns one entry per ASN with a nested list of communities; + this returns one CommunityEntry per community. + """ params = {} if asn: params["asn"] = asn @@ -51,13 +64,27 @@ def query( params["page"] = str(page) params["page_size"] = str(min(page_size, 1000)) - res = requests.get(self.base_url, params=params).json() - if isinstance(res, list): - return [CommunityEntry(**item) for item in res] - return [CommunityEntry(**item) for item in res.get("data", [])] + response = request_json(self.base_url, params=params, timeout=self.timeout) + + entries = [] + for item in response.get("data", []): + for community in item.get("communities", []) or []: + entries.append( + CommunityEntry( + asn=item.get("asn"), + value=community.get("community", ""), + description=community.get("description") or "", + as_name=item.get("name", ""), + country=item.get("country", ""), + source=community.get("source", ""), + range=community.get("range"), + ) + ) + return entries def sources(self) -> List[CommunitySource]: - res = requests.get(f"{self.base_url}/sources").json() - if isinstance(res, list): - return [CommunitySource(**item) for item in res] - return [CommunitySource(**item) for item in res.get("data", [])] + """List the community data sources.""" + response = request_json(f"{self.base_url}/sources", timeout=self.timeout) + if isinstance(response, list): + return [from_item(CommunitySource, item) for item in response] + return [from_item(CommunitySource, item) for item in response.get("sources", [])] diff --git a/bgpkit/bgpkit_roas.py b/bgpkit/bgpkit_roas.py index a8dde84..452fae4 100644 --- a/bgpkit/bgpkit_roas.py +++ b/bgpkit/bgpkit_roas.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from typing import List, Optional -import requests +from ._utils import DEFAULT_TIMEOUT, from_item, request_json + +DEFAULT_API_URL = "https://api.bgpkit.com/v3/roas" @dataclass @@ -15,14 +17,22 @@ class RoasItem: class Roas: - """BGPKIT ROAS lookup (alpha API). + """BGPKIT ROAS lookup (v3/roas). - Queries the ROAS (Route Origination Authorization) database - for historical and current RPKI data. + Queries the ROAS (Route Origin Authorization) database for historical and + current RPKI data. The base URL can be overridden per instance with + ``api_url``. """ - def __init__(self, api_url: str = "https://alpha.api.bgpkit.com"): + def __init__( + self, + api_url: str = DEFAULT_API_URL, + page_size: int = 100, + timeout: float = DEFAULT_TIMEOUT, + ): self.base_url = api_url.rstrip("/") + self.page_size = int(page_size) + self.timeout = timeout def query( self, @@ -30,18 +40,18 @@ def query( prefix: str = None, date: str = None, current: bool = None, - page: int = 1, + page: int = 0, page_size: int = None, ) -> List[RoasItem]: - """Query ROAS database. + """Query the ROAS database, fetching every page from ``page`` onward. Args: asn: AS number to filter by. prefix: IP prefix to filter by. date: Date string (YYYY-MM-DD) for historical lookup. current: If True, return only currently valid ROAs. - page: Page number (1-indexed). - page_size: Results per page. Defaults to 5 (alpha API limitation). + page: Page number to start from (0-indexed). + page_size: Results per page (defaults to the instance page size). Returns: List of RoasItem matching the query. @@ -55,10 +65,21 @@ def query( params["date"] = date if current is not None: params["current"] = str(current).lower() - params["page"] = str(page) - params["page_size"] = str(page_size if page_size is not None else 5) - res = requests.get(f"{self.base_url}/roas", params=params).json() - if isinstance(res, list): - return [RoasItem(**item) for item in res] - return [RoasItem(**item) for item in res.get("data", [])] + size = int(page_size) if page_size is not None else self.page_size + items: List[RoasItem] = [] + while True: + page_params = dict(params) + page_params["page"] = page + page_params["page_size"] = size + response = request_json(f"{self.base_url}/search", params=page_params, timeout=self.timeout) + + data = response.get("data", []) + items.extend(from_item(RoasItem, item) for item in data) + + total = response.get("total") + if not data or (total is not None and len(items) >= int(total)): + break + page += 1 + + return items diff --git a/tests/test_asn_unit.py b/tests/test_asn_unit.py new file mode 100644 index 0000000..4f4ca8f --- /dev/null +++ b/tests/test_asn_unit.py @@ -0,0 +1,87 @@ +"""Unit tests for AsnLookup, using recorded v3 API response shapes.""" + +import json +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, AsnInfo, AsnLookup + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(response): + return mock.patch("bgpkit._utils.requests.get", return_value=response) + + +ASN_PAYLOAD = { + "count": 1, + "data": [ + { + "as2org": { + "country": "US", + "name": "GOOGLE", + "org_id": "GOGL-ARIN", + "org_name": "Google LLC", + }, + "asn": 15169, + "country": "US", + "country_name": "United States", + "hegemony": {"asn": 15169, "ipv4": 0.0079, "ipv6": 0.0027}, + "name": "GOOGLE - Google LLC", + "peeringdb": {"asn": 15169, "name": "Google LLC", "irr_as_set": "RADB::AS-GOOGLE"}, + "population": {"percent_country": 0, "percent_global": 0, "user_count": 82}, + } + ], + "page": 0, + "page_size": 1, + "pagination": { + "page": 0, + "page_size": 1, + "total_pages": 1, + "total_count": 1, + "has_next": False, + "has_prev": False, + }, + "updatedAt": "2026-10-07T12:00:00", +} + + +class TestAsnLookup(unittest.TestCase): + def test_parses_nested_asn_item(self): + with mock_get(FakeResponse(ASN_PAYLOAD)) as get: + result = AsnLookup().query(asn="15169") + # the v3 ASN endpoint uses a 0-indexed page parameter + self.assertEqual(get.call_args.kwargs["params"]["page"], 0) + self.assertEqual(result.count, 1) + info = result.data[0] + self.assertIsInstance(info, AsnInfo) + self.assertEqual(info.asn, 15169) + self.assertEqual(info.name, "GOOGLE - Google LLC") + self.assertEqual(info.country_name, "United States") + self.assertEqual(info.org_name, "Google LLC") + self.assertEqual(info.org_id, "GOGL-ARIN") + self.assertEqual(info.hegemony, {"asn": 15169, "ipv4": 0.0079, "ipv6": 0.0027}) + self.assertEqual(result.updated_at, "2026-10-07T12:00:00") + self.assertIsNotNone(result.pagination) + self.assertEqual(result.pagination.total_count, 1) + self.assertFalse(result.pagination.has_next) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + AsnLookup().query(asn="15169") + self.assertIn("HTTP 500", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_broker_unit.py b/tests/test_broker_unit.py new file mode 100644 index 0000000..93ce273 --- /dev/null +++ b/tests/test_broker_unit.py @@ -0,0 +1,237 @@ +"""Unit tests for the Broker client, using recorded v3 API response shapes.""" + +import json +import os +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, Broker, BrokerItem, CollectorItem, PeerItem +from bgpkit.bgpkit_broker import DEFAULT_API_URL + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(*responses): + return mock.patch("bgpkit._utils.requests.get", side_effect=list(responses)) + + +LATEST_PAYLOAD = { + "count": 2, + "data": [ + { + "ts_start": "2026-10-07T14:00:00", + "ts_end": "2026-10-07T14:00:00", + "collector_id": "route-views.amsix", + "data_type": "rib", + "url": "http://archive.routeviews.org/route-views.amsix/bgpdata/2026.10/RIBS/rib.20261007.1400.bz2", + "rough_size": 57344000, + "exact_size": 0, + "delay": 533, + }, + { + "ts_start": "2026-10-07T13:45:00", + "ts_end": "2026-10-07T14:00:00", + "collector_id": "route-views.amsix", + "data_type": "updates", + "url": "http://archive.routeviews.org/route-views.amsix/bgpdata/2026.10/UPDATES/updates.20261007.1345.bz2", + "rough_size": 2185216, + "exact_size": 0, + "delay": 533, + }, + ], + "meta": {"latest_update_ts": "2026-10-07T14:08:53", "latest_update_duration": 1}, +} + +PEERS_PAYLOAD = { + "only_full_feed": False, + "count": 2, + "last_updated_at": "2026-10-07T14:00:00Z", + "data": [ + { + "date": "2026-10-06", + "collector": "rrc00", + "ip": "102.208.105.2", + "asn": 328840, + "num_v4_pfxs": 1062923, + "num_v6_pfxs": 0, + "num_connected_asns": 1, + }, + { + "date": "2026-10-06", + "collector": "rrc00", + "ip": "102.217.156.3", + "asn": 328977, + "num_v4_pfxs": 21163, + "num_v6_pfxs": 0, + "num_connected_asns": 120, + }, + ], +} + +COLLECTORS_PAYLOAD = { + "count": 2, + "last_updated_at": "2026-10-07T14:00:00.000Z", + "data": [ + { + "name": "route-views2", + "project": "routeviews", + "data_url": "http://archive.routeviews.org/bgpdata", + "activated_on": "2003-01-01T00:00:00.000Z", + "deactivated_on": None, + "country": "US", + }, + { + "name": "route-views.chile", + "project": "routeviews", + "data_url": "http://archive.routeviews.org/route-views.chile/bgpdata", + "activated_on": "2018-01-31T20:00:00.000Z", + "deactivated_on": "2026-06-01T21:27:00.000Z", + "country": "CL", + }, + ], +} + +SEARCH_ITEM_A = { + "ts_start": "2026-10-05T00:00:00", + "ts_end": "2026-10-05T00:00:00", + "collector_id": "rrc00", + "data_type": "rib", + "url": "https://data.ris.ripe.net/rrc00/2026.10/bview.20261005.0000.gz", + "rough_size": 146800640, + "exact_size": 0, +} +SEARCH_ITEM_B = { + "ts_start": "2026-10-05T00:00:00", + "ts_end": "2026-10-05T00:05:00", + "collector_id": "rrc00", + "data_type": "updates", + "url": "https://data.ris.ripe.net/rrc00/2026.10/updates.20261005.0000.gz", + "rough_size": 835584, + "exact_size": 0, +} +SEARCH_ITEM_C = { + "ts_start": "2026-10-05T00:05:00", + "ts_end": "2026-10-05T00:10:00", + "collector_id": "rrc00", + "data_type": "updates", + "url": "https://data.ris.ripe.net/rrc00/2026.10/updates.20261005.0005.gz", + "rough_size": 790528, + "exact_size": 0, +} + +SEARCH_PAGE_1 = { + "total": 3, + "count": 2, + "page": 1, + "page_size": 2, + "error": None, + "data": [SEARCH_ITEM_A, SEARCH_ITEM_B], + "meta": {"latest_update_ts": "2026-10-07T14:08:53", "latest_update_duration": 1}, +} +SEARCH_PAGE_2 = { + "total": 3, + "count": 1, + "page": 2, + "page_size": 2, + "error": None, + "data": [SEARCH_ITEM_C], + "meta": {"latest_update_ts": "2026-10-07T14:08:53", "latest_update_duration": 1}, +} +SEARCH_PAGE_WITHOUT_TOTAL = { + "count": 2, + "data": [SEARCH_ITEM_A, SEARCH_ITEM_B], +} + + +class TestBrokerLatest(unittest.TestCase): + def test_parses_items_with_delay_and_ignores_unknown_fields(self): + payload = json.loads(json.dumps(LATEST_PAYLOAD)) + payload["data"][0]["future_field"] = True + with mock_get(FakeResponse(payload)) as get: + items = Broker().latest() + self.assertEqual(len(items), 2) + self.assertIsInstance(items[0], BrokerItem) + self.assertEqual(items[0].collector_id, "route-views.amsix") + self.assertEqual(items[0].delay, 533) + self.assertEqual(get.call_count, 1) + + +class TestBrokerPeers(unittest.TestCase): + def test_parses_peer_items(self): + with mock_get(FakeResponse(PEERS_PAYLOAD)) as get: + peers = Broker(page_size=10).peers(collector="rrc00") + self.assertEqual(len(peers), 2) + self.assertIsInstance(peers[0], PeerItem) + self.assertEqual(peers[0].asn, 328840) + self.assertTrue(peers[0].full_feed) + self.assertFalse(peers[1].full_feed) + # the peers endpoint returns every match in one response + self.assertEqual(get.call_count, 1) + + +class TestBrokerCollectors(unittest.TestCase): + def test_parses_collector_items(self): + with mock_get(FakeResponse(COLLECTORS_PAYLOAD)) as get: + collectors = Broker(page_size=10).collectors(project="routeviews") + self.assertEqual(len(collectors), 2) + self.assertIsInstance(collectors[0], CollectorItem) + self.assertEqual(collectors[0].name, "route-views2") + self.assertTrue(collectors[0].active) + self.assertFalse(collectors[1].active) + self.assertEqual(collectors[1].deactivated_on, "2026-06-01T21:27:00.000Z") + self.assertEqual(get.call_count, 1) + + +class TestBrokerQuery(unittest.TestCase): + def test_paginates_until_total_is_reached(self): + responses = [FakeResponse(SEARCH_PAGE_1), FakeResponse(SEARCH_PAGE_2)] + with mock_get(*responses) as get: + items = Broker(page_size=2).query(collector_id="rrc00") + self.assertEqual([item.url for item in items], [SEARCH_ITEM_A["url"], SEARCH_ITEM_B["url"], SEARCH_ITEM_C["url"]]) + self.assertEqual(get.call_count, 2) + self.assertEqual(get.call_args_list[1].kwargs["params"]["page"], 2) + + def test_stops_when_a_page_adds_no_new_items(self): + responses = [FakeResponse(SEARCH_PAGE_WITHOUT_TOTAL), FakeResponse(SEARCH_PAGE_WITHOUT_TOTAL)] + with mock_get(*responses) as get: + items = Broker(page_size=2).query() + self.assertEqual(len(items), 2) + self.assertEqual(get.call_count, 2) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + Broker().query(collector_id="rrc00") + self.assertIn("HTTP 500", str(context.exception)) + + +class TestBrokerConfiguration(unittest.TestCase): + def test_default_api_url(self): + environment = {key: value for key, value in os.environ.items() if key != "BGPKIT_BROKER_URL"} + with mock.patch.dict(os.environ, environment, clear=True): + self.assertEqual(Broker().base_url, DEFAULT_API_URL) + + def test_environment_variable_overrides_default(self): + with mock.patch.dict(os.environ, {"BGPKIT_BROKER_URL": "https://broker.example.com/v2/broker/"}): + self.assertEqual(Broker().base_url, "https://broker.example.com/v2/broker") + + def test_explicit_argument_wins_over_environment(self): + with mock.patch.dict(os.environ, {"BGPKIT_BROKER_URL": "https://broker.example.com/v2/broker"}): + self.assertEqual( + Broker(api_url="https://other.example.com/broker").base_url, + "https://other.example.com/broker", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_community_unit.py b/tests/test_community_unit.py new file mode 100644 index 0000000..1808612 --- /dev/null +++ b/tests/test_community_unit.py @@ -0,0 +1,103 @@ +"""Unit tests for CommunityLookup, using recorded v3 API response shapes.""" + +import json +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, CommunityEntry, CommunityLookup, CommunitySource + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(response): + return mock.patch("bgpkit._utils.requests.get", return_value=response) + + +COMMUNITIES_PAYLOAD = { + "total_communities_all": 2, + "total_communities_on_page": 2, + "total_asns_on_page": 1, + "page": 0, + "page_size": 10, + "data": [ + { + "asn": 15169, + "name": "GOOGLE - Google LLC", + "country": "US", + "source": "asn_lookup", + "communities": [ + { + "community": "15169:10010", + "source": "nlnog", + "description": "GPRS Mobile Broadband", + "range": None, + }, + { + "community": "15169:10020", + "source": "nlnog", + "description": "2.5G/Edge Mobile Broadband", + "range": "10.0.0.0/8", + }, + ], + } + ], +} + +SOURCES_PAYLOAD = { + "sources": [ + { + "id": "nlnog", + "name": "NLNOG Looking Glass", + "url": "https://github.com/NLNOG/lg.ring.nlnog.net", + "last_updated": "2026-10-06T03:00:39.293Z", + "stats": {"total_asns": 107, "total_communities": 7104, "total_wildcards": 255}, + } + ] +} + + +class TestCommunityLookup(unittest.TestCase): + def test_flattens_communities_per_asn(self): + with mock_get(FakeResponse(COMMUNITIES_PAYLOAD)): + entries = CommunityLookup().query(asn="15169") + self.assertEqual(len(entries), 2) + entry = entries[0] + self.assertIsInstance(entry, CommunityEntry) + self.assertEqual(entry.asn, 15169) + self.assertEqual(entry.value, "15169:10010") + self.assertEqual(entry.description, "GPRS Mobile Broadband") + self.assertEqual(entry.as_name, "GOOGLE - Google LLC") + self.assertEqual(entry.country, "US") + self.assertEqual(entry.source, "nlnog") + self.assertIsNone(entry.range) + self.assertEqual(entries[1].range, "10.0.0.0/8") + + def test_parses_sources(self): + with mock_get(FakeResponse(SOURCES_PAYLOAD)): + sources = CommunityLookup().sources() + self.assertEqual(len(sources), 1) + source = sources[0] + self.assertIsInstance(source, CommunitySource) + self.assertEqual(source.id, "nlnog") + self.assertEqual(source.name, "NLNOG Looking Glass") + self.assertEqual(source.stats["total_communities"], 7104) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + CommunityLookup().query(asn="15169") + self.assertIn("HTTP 500", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py index a4e7e01..ba53047 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -33,11 +33,34 @@ def test_broker(self): collector_id="rrc00") self.assertGreater(len(items), 0) + latest = broker.latest() + self.assertGreater(len(latest), 100) + self.assertTrue(all(item.delay is not None for item in latest)) + + peers = broker.peers(collector="rrc00") + self.assertGreater(len(peers), 0) + + collectors = broker.collectors(project="routeviews") + self.assertGreater(len(collectors), 0) + def test_broker_no_verify(self): broker = bgpkit.Broker(verify=False) items = broker.query(ts_start="1643760000", ts_end="2022-02-02T00:20:00", collector_id="rrc00") self.assertGreater(len(items), 0) + def test_asn_lookup(self): + result = bgpkit.AsnLookup().query(asn="15169") + self.assertEqual(result.count, 1) + self.assertEqual(result.data[0].asn, 15169) + self.assertGreater(len(result.data[0].name), 0) + + def test_community_lookup(self): + lookup = bgpkit.CommunityLookup() + entries = lookup.query(asn="15169", page_size=1) + self.assertGreater(len(entries), 0) + sources = lookup.sources() + self.assertGreater(len(sources), 0) + def test_roas(self): roas = bgpkit.Roas() data = roas.query(asn=3333, date="2018-01-01") diff --git a/tests/test_roas_unit.py b/tests/test_roas_unit.py new file mode 100644 index 0000000..2c7c617 --- /dev/null +++ b/tests/test_roas_unit.py @@ -0,0 +1,90 @@ +"""Unit tests for Roas, using recorded v3 API response shapes.""" + +import json +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, Roas, RoasItem + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(*responses): + return mock.patch("bgpkit._utils.requests.get", side_effect=list(responses)) + + +ROAS_ITEM_A = { + "prefix": "193.0.20.0/23", + "max_len": 23, + "asn": 3333, + "date_ranges": [["2015-03-10", "2016-01-26"]], + "current": False, +} +ROAS_ITEM_B = { + "prefix": "193.0.22.0/23", + "max_len": 23, + "asn": 3333, + "date_ranges": [["2015-03-10", "2024-01-01"], ["2024-06-01", "2026-10-07"]], + "current": True, +} +ROAS_ITEM_C = { + "prefix": "193.0.24.0/23", + "max_len": 23, + "asn": 3333, + "date_ranges": [["2015-03-10", "2024-01-01"]], + "current": False, +} + +ROAS_PAGE_1 = { + "total": 3, + "page": 1, + "page_size": 2, + "error": None, + "data": [ROAS_ITEM_A, ROAS_ITEM_B], + "meta": {"latest_date": "2026-10-07"}, +} +ROAS_PAGE_2 = { + "total": 3, + "page": 2, + "page_size": 2, + "error": None, + "data": [ROAS_ITEM_C], + "meta": {"latest_date": "2026-10-07"}, +} + + +class TestRoas(unittest.TestCase): + def test_default_api_url_is_v3(self): + self.assertEqual(Roas().base_url, "https://api.bgpkit.com/v3/roas") + + def test_paginates_until_total_is_reached(self): + with mock_get(FakeResponse(ROAS_PAGE_1), FakeResponse(ROAS_PAGE_2)) as get: + items = Roas(page_size=2).query(asn=3333) + self.assertEqual([item.prefix for item in items], [ROAS_ITEM_A["prefix"], ROAS_ITEM_B["prefix"], ROAS_ITEM_C["prefix"]]) + self.assertIsInstance(items[0], RoasItem) + self.assertEqual(items[1].current, True) + self.assertEqual(get.call_count, 2) + self.assertTrue(get.call_args_list[0].args[0].endswith("/search")) + # the ROAS endpoint uses a 0-indexed page parameter + self.assertEqual(get.call_args_list[0].kwargs["params"]["page"], 0) + self.assertEqual(get.call_args_list[1].kwargs["params"]["page"], 1) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + Roas().query(asn=3333) + self.assertIn("HTTP 500", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils_unit.py b/tests/test_utils_unit.py new file mode 100644 index 0000000..3ba1605 --- /dev/null +++ b/tests/test_utils_unit.py @@ -0,0 +1,65 @@ +"""Unit tests for the shared API helpers.""" + +import json +import unittest +from dataclasses import dataclass +from unittest import mock + +import requests + +from bgpkit._utils import BGPKITApiError, from_item, request_json + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +class TestRequestJson(unittest.TestCase): + def test_returns_parsed_json(self): + with mock.patch( + "bgpkit._utils.requests.get", return_value=FakeResponse({"ok": True}) + ) as get: + result = request_json("https://api.example.com/x", params={"a": 1}) + self.assertEqual(result, {"ok": True}) + self.assertEqual(get.call_args.kwargs["params"], {"a": 1}) + + def test_wraps_transport_failures(self): + with mock.patch( + "bgpkit._utils.requests.get", side_effect=requests.ConnectionError("boom") + ): + with self.assertRaises(BGPKITApiError) as context: + request_json("https://api.example.com/x") + self.assertIn("failed", str(context.exception)) + + def test_wraps_timeouts(self): + with mock.patch("bgpkit._utils.requests.get", side_effect=requests.Timeout("too slow")): + with self.assertRaises(BGPKITApiError): + request_json("https://api.example.com/x") + + def test_wraps_non_json_bodies(self): + with mock.patch("bgpkit._utils.requests.get", return_value=FakeResponse(None)): + with self.assertRaises(BGPKITApiError) as context: + request_json("https://api.example.com/x") + self.assertIn("non-JSON", str(context.exception)) + + +class TestFromItem(unittest.TestCase): + def test_ignores_unknown_fields(self): + @dataclass + class Item: + a: int + b: str = "" + + self.assertEqual(from_item(Item, {"a": 1, "b": "x", "c": 2}), Item(a=1, b="x")) + + +if __name__ == "__main__": + unittest.main()