Skip to content

Commit 19d2b72

Browse files
committed
fix: Propagate API token to custom HTTP clients
1 parent 5ad7c91 commit 19d2b72

6 files changed

Lines changed: 160 additions & 9 deletions

File tree

docs/03_guides/code/05_custom_http_client_async.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ async def call(
3535
) -> HttpResponse:
3636
timeout_secs = self._compute_timeout(timeout, attempt=1) or 0
3737

38+
# Merge the client's default headers (including authorization)
39+
# with the per-request ones.
40+
headers = {**self._headers, **(headers or {})}
41+
3842
# httpx.Response satisfies the HttpResponse protocol,
3943
# so it can be returned directly.
4044
return await self._client.request(

docs/03_guides/code/05_custom_http_client_sync.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def call(
3434
) -> HttpResponse:
3535
timeout_secs = self._compute_timeout(timeout, attempt=1) or 0
3636

37+
# Merge the client's default headers (including authorization)
38+
# with the per-request ones.
39+
headers = {**self._headers, **(headers or {})}
40+
3741
# httpx.Response satisfies the HttpResponse protocol,
3842
# so it can be returned directly.
3943
return self._client.request(

src/apify_client/_apify_client.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def with_custom_http_client(
227227
228228
Use this alternative constructor when you want to provide your own HTTP client implementation
229229
instead of the default one. The custom client is responsible for its own configuration
230-
(retries, timeouts, headers, etc.).
230+
(retries, timeouts, etc.); only the token is applied to it, as described below.
231231
232232
### Usage
233233
@@ -246,12 +246,16 @@ def call(self, *, method, url, **kwargs) -> HttpResponse:
246246
```
247247
248248
Args:
249-
token: The Apify API token.
249+
token: The Apify API token. It is set as the `Authorization` header on the custom client,
250+
unless the client already has one configured.
250251
api_url: The URL of the Apify API server to connect to. Defaults to https://api.apify.com.
251252
api_public_url: The globally accessible URL of the Apify API server. Defaults to https://api.apify.com.
252253
http_client: A custom HTTP client instance extending `HttpClient`.
253254
"""
254255
instance = cls(token=token, api_url=api_url, api_public_url=api_public_url)
256+
client_headers = http_client._headers # noqa: SLF001
257+
if token is not None and not any(key.title() == 'Authorization' for key in client_headers):
258+
client_headers['Authorization'] = f'Bearer {token}'
255259
instance._http_client = http_client
256260
return instance
257261

@@ -586,7 +590,7 @@ def with_custom_http_client(
586590
587591
Use this alternative constructor when you want to provide your own HTTP client implementation
588592
instead of the default one. The custom client is responsible for its own configuration
589-
(retries, timeouts, headers, etc.).
593+
(retries, timeouts, etc.); only the token is applied to it, as described below.
590594
591595
### Usage
592596
@@ -605,12 +609,16 @@ async def call(self, *, method, url, **kwargs) -> HttpResponse:
605609
```
606610
607611
Args:
608-
token: The Apify API token.
612+
token: The Apify API token. It is set as the `Authorization` header on the custom client,
613+
unless the client already has one configured.
609614
api_url: The URL of the Apify API server to connect to. Defaults to https://api.apify.com.
610615
api_public_url: The globally accessible URL of the Apify API server. Defaults to https://api.apify.com.
611616
http_client: A custom HTTP client instance extending `HttpClientAsync`.
612617
"""
613618
instance = cls(token=token, api_url=api_url, api_public_url=api_public_url)
619+
client_headers = http_client._headers # noqa: SLF001
620+
if token is not None and not any(key.title() == 'Authorization' for key in client_headers):
621+
client_headers['Authorization'] = f'Bearer {token}'
614622
instance._http_client = http_client
615623
return instance
616624

src/apify_client/http_clients/_base.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,15 @@ def _prepare_request_call(
207207
data: str | bytes | bytearray | None = None,
208208
json: JsonSerializable | None = None,
209209
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
210-
"""Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body."""
210+
"""Prepare headers, params, and body for an HTTP request.
211+
212+
Merges the client's default headers (including authorization) with per-request headers,
213+
serializes JSON and compresses the body.
214+
"""
211215
if json is not None and data is not None:
212216
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
213217

214-
headers = dict(headers) if headers else {}
218+
headers = {**self._headers, **(headers or {})}
215219

216220
# Dump JSON data to string so it can be compressed.
217221
if json is not None:
@@ -252,6 +256,10 @@ class HttpClient(HttpClientBase, ABC):
252256
Extend this class to create a custom synchronous HTTP client. Override the `call` method
253257
with your implementation. Helper methods from the base class are available for request
254258
preparation, URL building, and parameter parsing.
259+
260+
Implementations must send the client's default headers from `self._headers` with every request,
261+
otherwise the `Authorization` header never reaches the API. The `_prepare_request_call` helper
262+
merges them into the per-request headers automatically.
255263
"""
256264

257265
@abstractmethod

tests/unit/test_http_clients.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,13 @@ def compressor_case(request: pytest.FixtureRequest) -> tuple:
279279

280280

281281
def test_prepare_request_call_basic() -> None:
282-
"""Test _prepare_request_call with basic parameters."""
283-
client = _ConcreteHttpClient()
282+
"""Test _prepare_request_call returns the client default headers when no per-request values are given."""
283+
client = _ConcreteHttpClient(token='test_token')
284284

285285
headers, params, data = client._prepare_request_call()
286-
assert headers == {}
286+
assert headers == client._headers
287+
assert headers is not client._headers
288+
assert headers['Authorization'] == 'Bearer test_token'
287289
assert params is None
288290
assert data is None
289291

tests/unit/test_pluggable_http_client.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
import json as jsonlib
34
from dataclasses import dataclass, field
45
from typing import TYPE_CHECKING, Any
56

7+
import impit
68
import pytest
9+
from werkzeug import Request, Response
710

811
import apify_client as apify_client_module
912
import apify_client.http_clients as http_clients_module
@@ -423,3 +426,125 @@ async def call(self, *, method: str, url: str, **kwargs: Any) -> HttpResponse:
423426

424427
assert result is not None
425428
assert result['data']['id'] == 'test-dataset'
429+
430+
431+
class PreparingHttpClient(HttpClient):
432+
"""A custom sync HTTP client that sends requests prepared by the base-class helpers."""
433+
434+
def __init__(self, token: str | None = None) -> None:
435+
super().__init__(token=token)
436+
self._impit_client = impit.Client()
437+
438+
def call(
439+
self,
440+
*,
441+
method: str,
442+
url: str,
443+
headers: dict[str, str] | None = None,
444+
params: dict[str, Any] | None = None,
445+
data: str | bytes | bytearray | None = None,
446+
json: Any = None,
447+
**_kwargs: Any,
448+
) -> HttpResponse:
449+
headers, params, content = self._prepare_request_call(headers=headers, params=params, data=data, json=json)
450+
url = self._build_url_with_params(url, params=params)
451+
return self._impit_client.request(method=method, url=url, headers=headers, content=content)
452+
453+
454+
class PreparingHttpClientAsync(HttpClientAsync):
455+
"""A custom async HTTP client that sends requests prepared by the base-class helpers."""
456+
457+
def __init__(self, token: str | None = None) -> None:
458+
super().__init__(token=token)
459+
self._impit_client = impit.AsyncClient()
460+
461+
async def call(
462+
self,
463+
*,
464+
method: str,
465+
url: str,
466+
headers: dict[str, str] | None = None,
467+
params: dict[str, Any] | None = None,
468+
data: str | bytes | bytearray | None = None,
469+
json: Any = None,
470+
**_kwargs: Any,
471+
) -> HttpResponse:
472+
headers, params, content = self._prepare_request_call(headers=headers, params=params, data=data, json=json)
473+
url = self._build_url_with_params(url, params=params)
474+
return await self._impit_client.request(method=method, url=url, headers=headers, content=content)
475+
476+
477+
def _echo_headers(request: Request) -> Response:
478+
"""Respond with the received request headers so tests can assert on them."""
479+
return Response(
480+
response=jsonlib.dumps({'received_headers': dict(request.headers)}),
481+
status=200,
482+
content_type='application/json',
483+
)
484+
485+
486+
def test_custom_http_client_sends_token_from_classmethod(httpserver: HTTPServer) -> None:
487+
"""Token passed to with_custom_http_client is sent as the Authorization header by the custom client."""
488+
httpserver.expect_request('/v2/datasets/test-dataset').respond_with_handler(_echo_headers)
489+
490+
api_url = httpserver.url_for('/').removesuffix('/')
491+
client = ApifyClient.with_custom_http_client(
492+
token='test_token',
493+
api_url=api_url,
494+
http_client=PreparingHttpClient(),
495+
)
496+
497+
result = client.dataset('test-dataset')._get(timeout='short')
498+
499+
assert result is not None
500+
assert result['received_headers']['Authorization'] == 'Bearer test_token'
501+
502+
503+
async def test_custom_http_client_async_sends_token_from_classmethod(httpserver: HTTPServer) -> None:
504+
"""Token passed to async with_custom_http_client is sent as the Authorization header by the custom client."""
505+
httpserver.expect_request('/v2/datasets/test-dataset').respond_with_handler(_echo_headers)
506+
507+
api_url = httpserver.url_for('/').removesuffix('/')
508+
client = ApifyClientAsync.with_custom_http_client(
509+
token='test_token',
510+
api_url=api_url,
511+
http_client=PreparingHttpClientAsync(),
512+
)
513+
514+
result = await client.dataset('test-dataset')._get(timeout='short')
515+
516+
assert result is not None
517+
assert result['received_headers']['Authorization'] == 'Bearer test_token'
518+
519+
520+
def test_custom_http_client_impit_instance_sends_token(httpserver: HTTPServer) -> None:
521+
"""Token from with_custom_http_client reaches the wire even for a pre-built tokenless ImpitHttpClient."""
522+
httpserver.expect_request('/v2/datasets/test-dataset').respond_with_handler(_echo_headers)
523+
524+
api_url = httpserver.url_for('/').removesuffix('/')
525+
client = ApifyClient.with_custom_http_client(token='test_token', api_url=api_url, http_client=ImpitHttpClient())
526+
527+
result = client.dataset('test-dataset')._get(timeout='short')
528+
529+
assert result is not None
530+
assert result['received_headers']['Authorization'] == 'Bearer test_token'
531+
532+
533+
def test_custom_http_client_keeps_own_token() -> None:
534+
"""An Authorization header configured on the custom client itself is not overridden by with_custom_http_client."""
535+
http_client = PreparingHttpClient(token='client_token')
536+
537+
ApifyClient.with_custom_http_client(token='outer_token', http_client=http_client)
538+
539+
assert http_client._headers['Authorization'] == 'Bearer client_token'
540+
541+
542+
def test_custom_http_client_keeps_differently_cased_authorization() -> None:
543+
"""A lowercase 'authorization' header on the custom client is not duplicated by the token injection."""
544+
http_client = PreparingHttpClient()
545+
http_client._headers['authorization'] = 'Bearer client_token'
546+
547+
ApifyClient.with_custom_http_client(token='outer_token', http_client=http_client)
548+
549+
assert 'Authorization' not in http_client._headers
550+
assert http_client._headers['authorization'] == 'Bearer client_token'

0 commit comments

Comments
 (0)