Skip to content

Commit da14809

Browse files
committed
fix: CamelCase undeclared request fields in add_request and batch_add_requests
1 parent 5ad7c91 commit da14809

2 files changed

Lines changed: 134 additions & 17 deletions

File tree

src/apify_client/_resource_clients/request_queue.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import TYPE_CHECKING, Any, Literal
88

99
from more_itertools import constrained_batches
10+
from pydantic.alias_generators import to_camel
1011

1112
from apify_client._docs import docs_group
1213
from apify_client._models import (
@@ -61,6 +62,21 @@
6162
_SAFETY_BUFFER_PERCENT = 0.01 / 100 # 0.01%
6263

6364

65+
def _dump_request_draft(request: RequestDraft) -> dict:
66+
"""Dump the request draft with camelCase aliases, including extra (undeclared) fields.
67+
68+
`RequestDraft` declares only a subset of the request fields; the rest (e.g. `user_data`) are captured as
69+
Pydantic extras, which bypass the `to_camel` alias generator on dump, so their keys have to be camelized here.
70+
An extra whose camelCase form is already present in the dump is kept as-is, so it never overwrites another field.
71+
"""
72+
dumped = request.model_dump(by_alias=True, exclude_none=True)
73+
for key in request.model_extra or {}:
74+
camel_key = to_camel(key)
75+
if key in dumped and camel_key not in dumped:
76+
dumped[camel_key] = dumped.pop(key)
77+
return dumped
78+
79+
6480
@docs_group('Resource clients')
6581
class RequestQueueClient(ResourceClient):
6682
"""Sub-client for managing a specific request queue.
@@ -223,7 +239,7 @@ def add_request(
223239
response = self._http_client.call(
224240
url=self._build_url('requests'),
225241
method='POST',
226-
json=request.model_dump(by_alias=True, exclude_none=True),
242+
json=_dump_request_draft(request),
227243
params=request_params,
228244
timeout=timeout,
229245
)
@@ -401,10 +417,7 @@ def batch_add_requests(
401417
raise NotImplementedError('max_parallel is only supported in async client')
402418

403419
requests_as_dicts = [
404-
(r if isinstance(r, RequestDraft) else RequestDraft.model_validate(r)).model_dump(
405-
by_alias=True, exclude_none=True
406-
)
407-
for r in requests
420+
_dump_request_draft(r if isinstance(r, RequestDraft) else RequestDraft.model_validate(r)) for r in requests
408421
]
409422

410423
request_params = self._build_params(clientKey=self.client_key, forefront=forefront)
@@ -750,7 +763,7 @@ async def add_request(
750763
response = await self._http_client.call(
751764
url=self._build_url('requests'),
752765
method='POST',
753-
json=request.model_dump(by_alias=True, exclude_none=True),
766+
json=_dump_request_draft(request),
754767
params=request_params,
755768
timeout=timeout,
756769
)
@@ -970,16 +983,7 @@ async def batch_add_requests(
970983
Result containing lists of processed and unprocessed requests.
971984
"""
972985
requests_as_dicts = [
973-
(
974-
request
975-
if isinstance(request, RequestDraft)
976-
else RequestDraft.model_validate(
977-
request,
978-
)
979-
).model_dump(
980-
by_alias=True,
981-
exclude_none=True,
982-
)
986+
_dump_request_draft(request if isinstance(request, RequestDraft) else RequestDraft.model_validate(request))
983987
for request in requests
984988
]
985989

tests/unit/test_client_request_queue.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
from __future__ import annotations
22

3+
import gzip
4+
import json
35
import re
4-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
57

8+
import brotli
69
import pytest
10+
from werkzeug import Request, Response
711

812
from apify_client import ApifyClient, ApifyClientAsync
913
from apify_client.errors import ApifyApiError
1014

1115
if TYPE_CHECKING:
16+
from collections.abc import Callable
17+
1218
from pytest_httpserver import HTTPServer
1319

1420
from apify_client._typeddicts import RequestDraftDict
@@ -117,3 +123,110 @@ def test_batch_processed_partially_sync(httpserver: HTTPServer) -> None:
117123
assert requests[0]['unique_key'] in {request.unique_key for request in batch_response.processed_requests}
118124
assert len(batch_response.unprocessed_requests) == 1
119125
assert batch_response.unprocessed_requests[0].unique_key == requests[1]['unique_key']
126+
127+
128+
_ADD_REQUEST_RESPONSE_CONTENT = """{
129+
"data": {
130+
"requestId": "YiKoxjkaS9gjGTqhF",
131+
"wasAlreadyPresent": false,
132+
"wasAlreadyHandled": false
133+
}
134+
}"""
135+
136+
_FULLY_ADDED_BATCH_RESPONSE_CONTENT = """{
137+
"data": {
138+
"processedRequests": [
139+
{
140+
"requestId": "YiKoxjkaS9gjGTqhF",
141+
"uniqueKey": "http://example.com/1",
142+
"wasAlreadyPresent": false,
143+
"wasAlreadyHandled": false
144+
}
145+
],
146+
"unprocessedRequests": []
147+
}
148+
}"""
149+
150+
_SNAKE_CASE_REQUEST: dict[str, Any] = {
151+
'unique_key': 'http://example.com/1',
152+
'url': 'http://example.com/1',
153+
'user_data': {'label': 'DETAIL'},
154+
'no_retry': True,
155+
}
156+
157+
_EXPECTED_CAMEL_CASE_REQUEST = {
158+
'uniqueKey': 'http://example.com/1',
159+
'url': 'http://example.com/1',
160+
'userData': {'label': 'DETAIL'},
161+
'noRetry': True,
162+
}
163+
164+
165+
def _make_json_capture_handler(received_bodies: list[Any], response_content: str) -> Callable[[Request], Response]:
166+
def handler(request: Request) -> Response:
167+
body = request.get_data()
168+
encoding = request.headers.get('Content-Encoding')
169+
if encoding == 'br':
170+
body = brotli.decompress(body)
171+
elif encoding == 'gzip':
172+
body = gzip.decompress(body)
173+
received_bodies.append(json.loads(body))
174+
return Response(status=201, response=response_content, content_type='application/json')
175+
176+
return handler
177+
178+
179+
def test_add_request_camel_cases_fields_undeclared_on_model_sync(httpserver: HTTPServer) -> None:
180+
"""Snake_case fields not declared on `RequestDraft` (e.g. `user_data`) are camelCased in the API payload."""
181+
server_url = httpserver.url_for('/').removesuffix('/')
182+
client = ApifyClient(token='placeholder_token', api_url=server_url, api_public_url=server_url)
183+
184+
received_bodies: list[Any] = []
185+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_handler(
186+
_make_json_capture_handler(received_bodies, _ADD_REQUEST_RESPONSE_CONTENT)
187+
)
188+
189+
client.request_queue(request_queue_id='whatever').add_request(_SNAKE_CASE_REQUEST) # ty: ignore[invalid-argument-type]
190+
assert received_bodies == [_EXPECTED_CAMEL_CASE_REQUEST]
191+
192+
193+
async def test_add_request_camel_cases_fields_undeclared_on_model_async(httpserver: HTTPServer) -> None:
194+
"""Snake_case fields not declared on `RequestDraft` (e.g. `user_data`) are camelCased in the API payload."""
195+
server_url = httpserver.url_for('/').removesuffix('/')
196+
client = ApifyClientAsync(token='placeholder_token', api_url=server_url, api_public_url=server_url)
197+
198+
received_bodies: list[Any] = []
199+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_handler(
200+
_make_json_capture_handler(received_bodies, _ADD_REQUEST_RESPONSE_CONTENT)
201+
)
202+
203+
await client.request_queue(request_queue_id='whatever').add_request(_SNAKE_CASE_REQUEST) # ty: ignore[invalid-argument-type]
204+
assert received_bodies == [_EXPECTED_CAMEL_CASE_REQUEST]
205+
206+
207+
def test_batch_add_requests_camel_cases_fields_undeclared_on_model_sync(httpserver: HTTPServer) -> None:
208+
"""Snake_case fields not declared on `RequestDraft` (e.g. `user_data`) are camelCased in the API payload."""
209+
server_url = httpserver.url_for('/').removesuffix('/')
210+
client = ApifyClient(token='placeholder_token', api_url=server_url, api_public_url=server_url)
211+
212+
received_bodies: list[Any] = []
213+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_handler(
214+
_make_json_capture_handler(received_bodies, _FULLY_ADDED_BATCH_RESPONSE_CONTENT)
215+
)
216+
217+
client.request_queue(request_queue_id='whatever').batch_add_requests(requests=[_SNAKE_CASE_REQUEST]) # ty: ignore[invalid-argument-type]
218+
assert received_bodies == [[_EXPECTED_CAMEL_CASE_REQUEST]]
219+
220+
221+
async def test_batch_add_requests_camel_cases_fields_undeclared_on_model_async(httpserver: HTTPServer) -> None:
222+
"""Snake_case fields not declared on `RequestDraft` (e.g. `user_data`) are camelCased in the API payload."""
223+
server_url = httpserver.url_for('/').removesuffix('/')
224+
client = ApifyClientAsync(token='placeholder_token', api_url=server_url, api_public_url=server_url)
225+
226+
received_bodies: list[Any] = []
227+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_handler(
228+
_make_json_capture_handler(received_bodies, _FULLY_ADDED_BATCH_RESPONSE_CONTENT)
229+
)
230+
231+
await client.request_queue(request_queue_id='whatever').batch_add_requests(requests=[_SNAKE_CASE_REQUEST]) # ty: ignore[invalid-argument-type]
232+
assert received_bodies == [[_EXPECTED_CAMEL_CASE_REQUEST]]

0 commit comments

Comments
 (0)