Skip to content

Commit 6417d26

Browse files
authored
feat: Add KeyValueStoreClient(Async).get_record_public_url (#506)
### Description - Add `KeyValueStoreClient.get_record_public_url`. - Add `KeyValueStoreClientAsync.get_record_public_url`. - Add tests. ### Issues Closes: #497
1 parent e9ba3f5 commit 6417d26

File tree

2 files changed

+170
-43
lines changed

2 files changed

+170
-43
lines changed

src/apify_client/clients/resource_clients/key_value_store.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import TYPE_CHECKING, Any
77
from urllib.parse import urlencode, urlparse, urlunparse
88

9-
from apify_shared.utils import create_storage_content_signature
9+
from apify_shared.utils import create_hmac_signature, create_storage_content_signature
1010

1111
from apify_client._utils import (
1212
catch_not_found_or_throw,
@@ -267,6 +267,36 @@ def delete_record(self, key: str) -> None:
267267
timeout_secs=_SMALL_TIMEOUT,
268268
)
269269

270+
def get_record_public_url(self, key: str) -> str:
271+
"""Generate a URL that can be used to access key-value store record.
272+
273+
If the client has permission to access the key-value store's URL signing key, the URL will include a signature
274+
to verify its authenticity.
275+
276+
Args:
277+
key: The key for which the URL should be generated.
278+
279+
Returns:
280+
A public URL that can be used to access the value of the given key in the KVS.
281+
"""
282+
if self.resource_id is None:
283+
raise ValueError('resource_id cannot be None when generating a public URL')
284+
285+
metadata = self.get()
286+
287+
request_params = self._params()
288+
289+
if metadata and 'urlSigningSecretKey' in metadata:
290+
request_params['signature'] = create_hmac_signature(metadata['urlSigningSecretKey'], key)
291+
292+
key_public_url = urlparse(self._url(f'records/{key}', public=True))
293+
filtered_params = {k: v for k, v in request_params.items() if v is not None}
294+
295+
if filtered_params:
296+
key_public_url = key_public_url._replace(query=urlencode(filtered_params))
297+
298+
return urlunparse(key_public_url)
299+
270300
def create_keys_public_url(
271301
self,
272302
*,
@@ -290,7 +320,7 @@ def create_keys_public_url(
290320
Returns:
291321
The public key-value store keys URL.
292322
"""
293-
store = self.get()
323+
metadata = self.get()
294324

295325
request_params = self._params(
296326
limit=limit,
@@ -299,10 +329,10 @@ def create_keys_public_url(
299329
prefix=prefix,
300330
)
301331

302-
if store and 'urlSigningSecretKey' in store:
332+
if metadata and 'urlSigningSecretKey' in metadata:
303333
signature = create_storage_content_signature(
304-
resource_id=store['id'],
305-
url_signing_secret_key=store['urlSigningSecretKey'],
334+
resource_id=metadata['id'],
335+
url_signing_secret_key=metadata['urlSigningSecretKey'],
306336
expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None,
307337
)
308338
request_params['signature'] = signature
@@ -555,6 +585,36 @@ async def delete_record(self, key: str) -> None:
555585
timeout_secs=_SMALL_TIMEOUT,
556586
)
557587

588+
async def get_record_public_url(self, key: str) -> str:
589+
"""Generate a URL that can be used to access key-value store record.
590+
591+
If the client has permission to access the key-value store's URL signing key, the URL will include a signature
592+
to verify its authenticity.
593+
594+
Args:
595+
key: The key for which the URL should be generated.
596+
597+
Returns:
598+
A public URL that can be used to access the value of the given key in the KVS.
599+
"""
600+
if self.resource_id is None:
601+
raise ValueError('resource_id cannot be None when generating a public URL')
602+
603+
metadata = await self.get()
604+
605+
request_params = self._params()
606+
607+
if metadata and 'urlSigningSecretKey' in metadata:
608+
request_params['signature'] = create_hmac_signature(metadata['urlSigningSecretKey'], key)
609+
610+
key_public_url = urlparse(self._url(f'records/{key}', public=True))
611+
filtered_params = {k: v for k, v in request_params.items() if v is not None}
612+
613+
if filtered_params:
614+
key_public_url = key_public_url._replace(query=urlencode(filtered_params))
615+
616+
return urlunparse(key_public_url)
617+
558618
async def create_keys_public_url(
559619
self,
560620
*,
@@ -578,7 +638,7 @@ async def create_keys_public_url(
578638
Returns:
579639
The public key-value store keys URL.
580640
"""
581-
store = await self.get()
641+
metadata = await self.get()
582642

583643
keys_public_url = urlparse(self._url('keys'))
584644

@@ -589,10 +649,10 @@ async def create_keys_public_url(
589649
prefix=prefix,
590650
)
591651

592-
if store and 'urlSigningSecretKey' in store:
652+
if metadata and 'urlSigningSecretKey' in metadata:
593653
signature = create_storage_content_signature(
594-
resource_id=store['id'],
595-
url_signing_secret_key=store['urlSigningSecretKey'],
654+
resource_id=metadata['id'],
655+
url_signing_secret_key=metadata['urlSigningSecretKey'],
596656
expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None,
597657
)
598658
request_params['signature'] = signature

tests/integration/test_key_value_store.py

Lines changed: 101 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,43 @@
11
from __future__ import annotations
22

3+
import json
34
from unittest import mock
45
from unittest.mock import Mock
56

67
import impit
8+
import pytest
9+
from apify_shared.utils import create_hmac_signature, create_storage_content_signature
710

811
from integration.integration_test_utils import parametrized_api_urls, random_resource_name
912

1013
from apify_client import ApifyClient, ApifyClientAsync
1114
from apify_client.client import DEFAULT_API_URL
1215

13-
MOCKED_API_KVS_RESPONSE = """{
14-
"data": {
15-
"id": "someID",
16-
"name": "name",
17-
"userId": "userId",
18-
"createdAt": "2025-09-11T08:48:51.806Z",
19-
"modifiedAt": "2025-09-11T08:48:51.806Z",
20-
"accessedAt": "2025-09-11T08:48:51.806Z",
21-
"actId": null,
22-
"actRunId": null,
23-
"schema": null,
24-
"stats": {
25-
"readCount": 0,
26-
"writeCount": 0,
27-
"deleteCount": 0,
28-
"listCount": 0,
29-
"storageBytes": 0
30-
},
31-
"consoleUrl": "https://console.apify.com/storage/key-value-stores/someID",
32-
"keysPublicUrl": "https://api.apify.com/v2/key-value-stores/someID/keys",
33-
"generalAccess": "FOLLOW_USER_SETTING",
34-
"urlSigningSecretKey": "urlSigningSecretKey"
35-
}
36-
}"""
16+
MOCKED_ID = 'someID'
17+
18+
19+
def _get_mocked_api_kvs_response(signing_key: str | None = None) -> str:
20+
response_data = {
21+
'data': {
22+
'id': MOCKED_ID,
23+
'name': 'name',
24+
'userId': 'userId',
25+
'createdAt': '2025-09-11T08:48:51.806Z',
26+
'modifiedAt': '2025-09-11T08:48:51.806Z',
27+
'accessedAt': '2025-09-11T08:48:51.806Z',
28+
'actId': None,
29+
'actRunId': None,
30+
'schema': None,
31+
'stats': {'readCount': 0, 'writeCount': 0, 'deleteCount': 0, 'listCount': 0, 'storageBytes': 0},
32+
'consoleUrl': 'https://console.apify.com/storage/key-value-stores/someID',
33+
'keysPublicUrl': 'https://api.apify.com/v2/key-value-stores/someID/keys',
34+
'generalAccess': 'FOLLOW_USER_SETTING',
35+
}
36+
}
37+
if signing_key:
38+
response_data['data']['urlSigningSecretKey'] = signing_key
39+
40+
return json.dumps(response_data)
3741

3842

3943
class TestKeyValueStoreSync:
@@ -73,17 +77,48 @@ def test_key_value_store_should_create_public_keys_non_expiring_url(self, apify_
7377
store.delete()
7478
assert apify_client.key_value_store(created_store['id']).get() is None
7579

80+
@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key'])
7681
@parametrized_api_urls
77-
def test_public_url(self, api_token: str, api_url: str, api_public_url: str) -> None:
82+
def test_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None:
7883
apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url)
79-
kvs = apify_client.key_value_store('someID')
84+
kvs = apify_client.key_value_store(MOCKED_ID)
8085

8186
# Mock the API call to return predefined response
82-
with mock.patch.object(apify_client.http_client, 'call', return_value=Mock(text=MOCKED_API_KVS_RESPONSE)):
87+
with mock.patch.object(
88+
apify_client.http_client,
89+
'call',
90+
return_value=Mock(text=_get_mocked_api_kvs_response(signing_key=signing_key)),
91+
):
8392
public_url = kvs.create_keys_public_url()
93+
if signing_key:
94+
signature_value = create_storage_content_signature(
95+
resource_id=MOCKED_ID, url_signing_secret_key=signing_key
96+
)
97+
expected_signature = f'?signature={signature_value}'
98+
else:
99+
expected_signature = ''
84100
assert public_url == (
85-
f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/'
86-
f'someID/keys?signature={public_url.split("signature=")[1]}'
101+
f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/keys{expected_signature}'
102+
)
103+
104+
@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key'])
105+
@parametrized_api_urls
106+
def test_record_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None:
107+
apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url)
108+
key = 'some_key'
109+
kvs = apify_client.key_value_store(MOCKED_ID)
110+
111+
# Mock the API call to return predefined response
112+
with mock.patch.object(
113+
apify_client.http_client,
114+
'call',
115+
return_value=Mock(text=_get_mocked_api_kvs_response(signing_key=signing_key)),
116+
):
117+
public_url = kvs.get_record_public_url(key=key)
118+
expected_signature = f'?signature={create_hmac_signature(signing_key, key)}' if signing_key else ''
119+
assert public_url == (
120+
f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/'
121+
f'records/{key}{expected_signature}'
87122
)
88123

89124

@@ -130,15 +165,47 @@ async def test_key_value_store_should_create_public_keys_non_expiring_url(
130165
await store.delete()
131166
assert await apify_client_async.key_value_store(created_store['id']).get() is None
132167

168+
@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key'])
133169
@parametrized_api_urls
134-
async def test_public_url(self, api_token: str, api_url: str, api_public_url: str) -> None:
170+
async def test_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None:
135171
apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url)
136-
kvs = apify_client.key_value_store('someID')
172+
kvs = apify_client.key_value_store(MOCKED_ID)
173+
mocked_response = _get_mocked_api_kvs_response(signing_key=signing_key)
137174

138175
# Mock the API call to return predefined response
139-
with mock.patch.object(apify_client.http_client, 'call', return_value=Mock(text=MOCKED_API_KVS_RESPONSE)):
176+
with mock.patch.object(
177+
apify_client.http_client,
178+
'call',
179+
return_value=Mock(text=mocked_response),
180+
):
140181
public_url = await kvs.create_keys_public_url()
182+
if signing_key:
183+
signature_value = create_storage_content_signature(
184+
resource_id=MOCKED_ID, url_signing_secret_key=signing_key
185+
)
186+
expected_signature = f'?signature={signature_value}'
187+
else:
188+
expected_signature = ''
189+
assert public_url == (
190+
f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/keys{expected_signature}'
191+
)
192+
193+
@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key'])
194+
@parametrized_api_urls
195+
async def test_record_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None:
196+
apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url)
197+
key = 'some_key'
198+
kvs = apify_client.key_value_store(MOCKED_ID)
199+
200+
# Mock the API call to return predefined response
201+
with mock.patch.object(
202+
apify_client.http_client,
203+
'call',
204+
return_value=Mock(text=_get_mocked_api_kvs_response(signing_key=signing_key)),
205+
):
206+
public_url = await kvs.get_record_public_url(key=key)
207+
expected_signature = f'?signature={create_hmac_signature(signing_key, key)}' if signing_key else ''
141208
assert public_url == (
142-
f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/'
143-
f'someID/keys?signature={public_url.split("signature=")[1]}'
209+
f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/'
210+
f'records/{key}{expected_signature}'
144211
)

0 commit comments

Comments
 (0)