Skip to content

Commit 3a17603

Browse files
support api key
1 parent a061d5e commit 3a17603

7 files changed

Lines changed: 188 additions & 16 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "decodo-sdk"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
description = "Official Python SDK for the Decodo APIs"
55
readme = "README.md"
66
license = { file = "LICENSE" }

‎src/decodo/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .api.web_scraping_api import WebScrapingApi
1+
from .api.web_scraping_api import WebScrapingApi, WebScrapingApiRoutes
22
from .client import DecodoClient, DecodoConfig, WebScrapingApiConfig
33
from .errors import (
44
AuthenticationError,
@@ -268,6 +268,7 @@ def __getattr__(name: str) -> object:
268268
"DecodoConfig",
269269
"WebScrapingApiConfig",
270270
"WebScrapingApi",
271+
"WebScrapingApiRoutes",
271272
"BundledSchema",
272273
"RemoteSchema",
273274
"DecodoSchema",

‎src/decodo/api/web_scraping_api.py‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Mapping
4+
from dataclasses import dataclass
45
from typing import TYPE_CHECKING, Any, cast
56

67
import jsonschema
@@ -22,16 +23,32 @@
2223
)
2324

2425

26+
@dataclass(frozen=True)
27+
class WebScrapingApiRoutes:
28+
scrape: str
29+
task: str
30+
31+
32+
SCRAPER_API_ROUTES = WebScrapingApiRoutes(scrape="/v2/scrape", task="/v3/task")
33+
DATA_API_ROUTES = WebScrapingApiRoutes(scrape="/v1/scrape", task="/v1/task")
34+
35+
2536
def _to_payload(params: ScrapeRequest | BatchRequest | Mapping[str, Any]) -> dict[str, Any]:
2637
if isinstance(params, BaseModel):
2738
return params.model_dump(by_alias=True, exclude_none=True, mode="json")
2839
return dict(params)
2940

3041

3142
class WebScrapingApi:
32-
def __init__(self, http: HttpClient, schema: DecodoSchema = BundledSchema.shared) -> None:
43+
def __init__(
44+
self,
45+
http: HttpClient,
46+
schema: DecodoSchema = BundledSchema.shared,
47+
routes: WebScrapingApiRoutes = SCRAPER_API_ROUTES,
48+
) -> None:
3349
self._http = http
3450
self._schema = schema
51+
self._routes = routes
3552

3653
def _validate(self, payload: dict[str, Any]) -> None:
3754
if self._schema is None:
@@ -55,20 +72,20 @@ def _validate(self, payload: dict[str, Any]) -> None:
5572
def scrape(self, params: ScrapeRequest | Mapping[str, Any]) -> SyncResponse:
5673
payload = _to_payload(params)
5774
self._validate(payload)
58-
return cast(SyncResponse, self._http.post("/v2/scrape", payload))
75+
return cast(SyncResponse, self._http.post(self._routes.scrape, payload))
5976

6077
def scrape_async(self, params: ScrapeRequest | Mapping[str, Any]) -> AsyncTaskResponse:
6178
payload = _to_payload(params)
6279
self._validate(payload)
63-
return cast(AsyncTaskResponse, self._http.post("/v3/task", payload))
80+
return cast(AsyncTaskResponse, self._http.post(self._routes.task, payload))
6481

6582
def scrape_batch(self, params: BatchRequest | Mapping[str, Any]) -> BatchResponse:
6683
payload = _to_payload(params)
67-
return cast(BatchResponse, self._http.post("/v3/task/batch", payload))
84+
return cast(BatchResponse, self._http.post(f"{self._routes.task}/batch", payload))
6885

6986
def get_status(self, task_id: str) -> TaskMetadata:
70-
return cast(TaskMetadata, self._http.get(f"/v3/task/{task_id}"))
87+
return cast(TaskMetadata, self._http.get(f"{self._routes.task}/{task_id}"))
7188

7289
def get_results(self, task_id: str) -> TaskResultsResponse | None:
73-
result = self._http.get(f"/v3/task/{task_id}/results")
90+
result = self._http.get(f"{self._routes.task}/{task_id}/results")
7491
return cast(TaskResultsResponse, result) if result is not None else None

‎src/decodo/client.py‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@
33
from dataclasses import dataclass
44
from typing import Any
55

6-
from .api.web_scraping_api import WebScrapingApi
7-
from .http import BasicAuth, HttpClient, HttpClientConfig
6+
from .api.web_scraping_api import (
7+
DATA_API_ROUTES,
8+
SCRAPER_API_ROUTES,
9+
WebScrapingApi,
10+
WebScrapingApiRoutes,
11+
)
12+
from .http import ApiKeyAuth, BasicAuth, HttpClient, HttpClientConfig
813
from .schema.bundled_schema import BundledSchema
914
from .schema.types import DecodoSchema
1015

1116
WEB_API_BASE_URL = "https://scraper-api.decodo.com"
17+
DATA_API_BASE_URL = "https://data.decodo.com"
1218
DEFAULT_TIMEOUT_MS = 180_000
1319

1420

1521
@dataclass
1622
class WebScrapingApiConfig:
17-
token: str
23+
token: str | None = None
24+
api_key: str | None = None
1825
integration_header: str | None = None
1926

2027

@@ -25,6 +32,43 @@ class DecodoConfig:
2532
schema: DecodoSchema | None = None
2633

2734

35+
@dataclass(frozen=True)
36+
class _Transport:
37+
base_url: str
38+
auth: BasicAuth | ApiKeyAuth
39+
routes: WebScrapingApiRoutes
40+
41+
42+
def _present(value: str | None) -> str | None:
43+
return value if value is not None and value.strip() else None
44+
45+
46+
def _resolve_transport(config: WebScrapingApiConfig) -> _Transport:
47+
token = _present(config.token)
48+
api_key = _present(config.api_key)
49+
50+
if token is not None and api_key is not None:
51+
raise ValueError(
52+
"web_scraping_api accepts either token or api_key, not both. Provide only one."
53+
)
54+
55+
if api_key is not None:
56+
return _Transport(
57+
base_url=DATA_API_BASE_URL,
58+
auth=ApiKeyAuth(type="apiKey", api_key=api_key),
59+
routes=DATA_API_ROUTES,
60+
)
61+
62+
if token is not None:
63+
return _Transport(
64+
base_url=WEB_API_BASE_URL,
65+
auth=BasicAuth(type="basic", token=token),
66+
routes=SCRAPER_API_ROUTES,
67+
)
68+
69+
raise ValueError("web_scraping_api requires token in DecodoConfig.")
70+
71+
2872
def _not_configured(namespace: str, hint: str) -> Any:
2973
raise RuntimeError(f"{namespace} is not configured. {hint}")
3074

@@ -45,14 +89,15 @@ def __init__(self, config: DecodoConfig) -> None:
4589
schema: DecodoSchema = config.schema if config.schema is not None else BundledSchema.shared
4690

4791
if config.web_scraping_api is not None:
92+
transport = _resolve_transport(config.web_scraping_api)
4893
http = HttpClient(
4994
HttpClientConfig(
50-
base_url=WEB_API_BASE_URL,
51-
auth=BasicAuth(type="basic", token=config.web_scraping_api.token),
95+
base_url=transport.base_url,
96+
auth=transport.auth,
5297
timeout_ms=timeout_ms,
5398
integration_header=config.web_scraping_api.integration_header,
5499
)
55100
)
56-
self.web_scraping_api = WebScrapingApi(http, schema)
101+
self.web_scraping_api = WebScrapingApi(http, schema, transport.routes)
57102
else:
58103
self.web_scraping_api = _UnconfiguredWebScrapingApi() # type: ignore[assignment]

‎src/decodo/http.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(self, config: HttpClientConfig) -> None:
4646
self._auth_header = f"Basic {config.auth.token}"
4747
else:
4848
assert isinstance(config.auth, ApiKeyAuth)
49-
self._auth_header = config.auth.api_key
49+
self._auth_header = f"Bearer {config.auth.api_key}"
5050

5151
def request(self, method: str, path: str, body: Any = None) -> Any:
5252
url = f"{self._base_url}{path}"

‎tests/test_client.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
from unittest.mock import MagicMock, patch
5+
6+
import httpx
7+
import pytest
8+
9+
from decodo.client import DecodoClient, DecodoConfig, WebScrapingApiConfig
10+
11+
SCRAPE_PARAMS = {"target": "universal", "url": "https://example.com"}
12+
13+
14+
def _make_response(body: Any, status: int = 200) -> MagicMock:
15+
mock = MagicMock(spec=httpx.Response)
16+
mock.status_code = status
17+
mock.is_success = 200 <= status < 300
18+
mock.json.return_value = body
19+
return mock
20+
21+
22+
class TestTransportSelection:
23+
def _collect_urls(self, config: WebScrapingApiConfig) -> list[str]:
24+
urls: list[str] = []
25+
26+
def fake_request(method: str, url: str, **kwargs: Any) -> MagicMock:
27+
urls.append(url)
28+
return _make_response({"results": []})
29+
30+
with patch("httpx.request", side_effect=fake_request):
31+
client = DecodoClient(DecodoConfig(web_scraping_api=config))
32+
client.web_scraping_api.scrape(SCRAPE_PARAMS)
33+
client.web_scraping_api.get_status("task-1")
34+
35+
return urls
36+
37+
def test_routes_a_token_to_scraper_api_over_v2_v3(self) -> None:
38+
assert self._collect_urls(WebScrapingApiConfig(token="test-token")) == [
39+
"https://scraper-api.decodo.com/v2/scrape",
40+
"https://scraper-api.decodo.com/v3/task/task-1",
41+
]
42+
43+
def test_routes_an_api_key_to_data_decodo_com_over_v1(self) -> None:
44+
assert self._collect_urls(WebScrapingApiConfig(api_key="test-key")) == [
45+
"https://data.decodo.com/v1/scrape",
46+
"https://data.decodo.com/v1/task/task-1",
47+
]
48+
49+
def test_raises_when_both_token_and_api_key_are_provided(self) -> None:
50+
with pytest.raises(ValueError, match="either token or api_key"):
51+
DecodoClient(
52+
DecodoConfig(
53+
web_scraping_api=WebScrapingApiConfig(
54+
token="test-token", api_key="test-key"
55+
)
56+
)
57+
)
58+
59+
def test_raises_when_neither_token_nor_api_key_is_provided(self) -> None:
60+
with pytest.raises(ValueError, match="web_scraping_api requires"):
61+
DecodoClient(DecodoConfig(web_scraping_api=WebScrapingApiConfig()))
62+
63+
@pytest.mark.parametrize("blank", ["", " "])
64+
def test_raises_when_the_only_credential_is_blank(self, blank: str) -> None:
65+
with pytest.raises(ValueError, match="web_scraping_api requires"):
66+
DecodoClient(DecodoConfig(web_scraping_api=WebScrapingApiConfig(token=blank)))
67+
with pytest.raises(ValueError, match="web_scraping_api requires"):
68+
DecodoClient(DecodoConfig(web_scraping_api=WebScrapingApiConfig(api_key=blank)))
69+
70+
@pytest.mark.parametrize("blank", ["", " "])
71+
def test_blank_credential_does_not_shadow_the_other_one(self, blank: str) -> None:
72+
assert self._collect_urls(WebScrapingApiConfig(token=blank, api_key="test-key")) == [
73+
"https://data.decodo.com/v1/scrape",
74+
"https://data.decodo.com/v1/task/task-1",
75+
]
76+
assert self._collect_urls(WebScrapingApiConfig(token="test-token", api_key=blank)) == [
77+
"https://scraper-api.decodo.com/v2/scrape",
78+
"https://scraper-api.decodo.com/v3/task/task-1",
79+
]

‎tests/test_http.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import httpx
77

8-
from decodo.http import BasicAuth, HttpClient, HttpClientConfig
8+
from decodo.http import ApiKeyAuth, BasicAuth, HttpClient, HttpClientConfig
99

1010
BASE_CONFIG = HttpClientConfig(
1111
base_url="https://api.test",
@@ -55,3 +55,33 @@ def fake_request(method: str, url: str, **kwargs: Any) -> MagicMock:
5555
client.get("/v3/task/1")
5656

5757
assert captured["headers"]["x-integration"] == "cli"
58+
59+
60+
class TestHttpClientAuthHeader:
61+
def _captured_headers(self, config: HttpClientConfig) -> dict[str, str]:
62+
captured: dict[str, dict[str, str]] = {}
63+
64+
def fake_request(method: str, url: str, **kwargs: Any) -> MagicMock:
65+
captured["headers"] = kwargs.get("headers", {})
66+
return _make_response({"ok": True})
67+
68+
with patch("httpx.request", side_effect=fake_request):
69+
HttpClient(config).get("/v3/task/1")
70+
71+
return captured["headers"]
72+
73+
def test_sends_basic_auth_for_a_token(self) -> None:
74+
headers = self._captured_headers(BASE_CONFIG)
75+
76+
assert headers["Authorization"] == "Basic test-token"
77+
78+
def test_sends_bearer_auth_for_an_api_key(self) -> None:
79+
headers = self._captured_headers(
80+
HttpClientConfig(
81+
base_url="https://api.test",
82+
auth=ApiKeyAuth(type="apiKey", api_key="test-key"),
83+
timeout_ms=5000,
84+
)
85+
)
86+
87+
assert headers["Authorization"] == "Bearer test-key"

0 commit comments

Comments
 (0)