Skip to content

Commit 62554e4

Browse files
authored
feat: add new methods Dataset.createItemsPublicUrl & KeyValueStore.createKeysPublicUrl (#720)
When storage resources (Datasets or Key-Value Stores) are set to Restricted, accessing or sharing their data externally becomes difficult due to limited permissions. This PR introduces functionality to generate signed URLs that allow controlled external access to these resources without adding token to the request. This PR introduces methods to generate signed URLs for Dataset items and Key-Value Store records: 1. **Datasets** `dataset(:datasetId).createItemsPublicUrl(options, expiresInMillis)` → Returns a signed URL like: `/v2/datasets/:datasetId/items?signature=xxx` 2. Key-Value Stores `keyValueStore(:storeId).createKeysPublicUrl(options, expiresInMillis)` → Returns a signed URL like: `/v2/key-value-stores/:storeId/keys?signature=xxx` 🕒 Expiration: The `expiresInMillis` parameter defines how long the signature is valid. - If provided, the URL will expire after the specified time. - If omitted, the URL will never expire. Note: The signature is included only if the token has WRITE access to the storage. Otherwise, an unsigned URL is returned. P.S. We're not yet exposing `urlSigningSecretKey` for datasets, it will be released after [PR](apify/apify-core#22173) is merged. [More context here](https://www.notion.so/apify/Signed-Dataset-Items-KV-store-record-URLs-224f39950a2280158a6bd82bc2e2ebb5?source=copy_link)
1 parent 8eab23e commit 62554e4

File tree

9 files changed

+245
-8
lines changed

9 files changed

+245
-8
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"dependencies": {
6565
"@apify/consts": "^2.25.0",
6666
"@apify/log": "^2.2.6",
67+
"@apify/utilities": "^2.18.0",
6768
"@crawlee/types": "^3.3.0",
6869
"agentkeepalive": "^4.2.1",
6970
"async-retry": "^1.3.3",

src/resource_clients/dataset.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ow from 'ow';
22

33
import type { STORAGE_GENERAL_ACCESS } from '@apify/consts';
4+
import { createStorageContentSignature } from '@apify/utilities';
45

56
import type { ApifyApiError } from '../apify_api_error';
67
import type { ApiClientSubResourceOptions } from '../base/api_client';
@@ -12,7 +13,7 @@ import {
1213
} from '../base/resource_client';
1314
import type { ApifyRequestConfig, ApifyResponse } from '../http_client';
1415
import type { PaginatedList } from '../utils';
15-
import { cast, catchNotFoundOrThrow, pluckData } from '../utils';
16+
import { applyQueryParamsToUrl, cast, catchNotFoundOrThrow, pluckData } from '../utils';
1617

1718
export class DatasetClient<
1819
Data extends Record<string | number, any> = Record<string | number, unknown>,
@@ -163,6 +164,54 @@ export class DatasetClient<
163164
return undefined;
164165
}
165166

167+
/**
168+
* Generates a URL that can be used to access dataset items.
169+
*
170+
* If the client has permission to access the dataset's URL signing key,
171+
* the URL will include a signature to verify its authenticity.
172+
*
173+
* You can optionally control how long the signed URL should be valid using the `expiresInMillis` option.
174+
* This value sets the expiration duration in milliseconds from the time the URL is generated.
175+
* If not provided, the URL will not expire.
176+
*
177+
* Any other options (like `limit` or `prefix`) will be included as query parameters in the URL.
178+
*/
179+
async createItemsPublicUrl(options: DatasetClientListItemOptions = {}, expiresInMillis?: number): Promise<string> {
180+
ow(
181+
options,
182+
ow.object.exactShape({
183+
clean: ow.optional.boolean,
184+
desc: ow.optional.boolean,
185+
flatten: ow.optional.array.ofType(ow.string),
186+
fields: ow.optional.array.ofType(ow.string),
187+
omit: ow.optional.array.ofType(ow.string),
188+
limit: ow.optional.number,
189+
offset: ow.optional.number,
190+
skipEmpty: ow.optional.boolean,
191+
skipHidden: ow.optional.boolean,
192+
unwind: ow.optional.any(ow.string, ow.array.ofType(ow.string)),
193+
view: ow.optional.string,
194+
}),
195+
);
196+
197+
const dataset = await this.get();
198+
199+
let createdItemsPublicUrl = new URL(this._url('items'));
200+
201+
if (dataset?.urlSigningSecretKey) {
202+
const signature = createStorageContentSignature({
203+
resourceId: dataset.id,
204+
urlSigningSecretKey: dataset.urlSigningSecretKey,
205+
expiresInMillis,
206+
});
207+
createdItemsPublicUrl.searchParams.set('signature', signature);
208+
}
209+
210+
createdItemsPublicUrl = applyQueryParamsToUrl(createdItemsPublicUrl, options);
211+
212+
return createdItemsPublicUrl.toString();
213+
}
214+
166215
private _createPaginationList(response: ApifyResponse, userProvidedDesc: boolean): PaginatedList<Data> {
167216
return {
168217
items: response.data,
@@ -191,6 +240,8 @@ export interface Dataset {
191240
stats: DatasetStats;
192241
fields: string[];
193242
generalAccess?: STORAGE_GENERAL_ACCESS | null;
243+
urlSigningSecretKey?: string | null;
244+
itemsPublicUrl: string;
194245
}
195246

196247
export interface DatasetStats {

src/resource_clients/key_value_store.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { JsonValue } from 'type-fest';
55

66
import type { STORAGE_GENERAL_ACCESS } from '@apify/consts';
77
import log from '@apify/log';
8+
import { createStorageContentSignature } from '@apify/utilities';
89

910
import type { ApifyApiError } from '../apify_api_error';
1011
import type { ApiClientSubResourceOptions } from '../base/api_client';
@@ -15,7 +16,16 @@ import {
1516
SMALL_TIMEOUT_MILLIS,
1617
} from '../base/resource_client';
1718
import type { ApifyRequestConfig } from '../http_client';
18-
import { cast, catchNotFoundOrThrow, isBuffer, isNode, isStream, parseDateFields, pluckData } from '../utils';
19+
import {
20+
applyQueryParamsToUrl,
21+
cast,
22+
catchNotFoundOrThrow,
23+
isBuffer,
24+
isNode,
25+
isStream,
26+
parseDateFields,
27+
pluckData,
28+
} from '../utils';
1929

2030
export class KeyValueStoreClient extends ResourceClient {
2131
/**
@@ -75,6 +85,48 @@ export class KeyValueStoreClient extends ResourceClient {
7585
return cast(parseDateFields(pluckData(response.data)));
7686
}
7787

88+
/**
89+
* Generates a URL that can be used to access key-value store keys.
90+
*
91+
* If the client has permission to access the key-value store's URL signing key,
92+
* the URL will include a signature to verify its authenticity.
93+
*
94+
* You can optionally control how long the signed URL should be valid using the `expiresInMillis` option.
95+
* This value sets the expiration duration in milliseconds from the time the URL is generated.
96+
* If not provided, the URL will not expire.
97+
*
98+
* Any other options (like `limit` or `prefix`) will be included as query parameters in the URL.
99+
*
100+
*/
101+
async createKeysPublicUrl(options: KeyValueClientListKeysOptions = {}, expiresInMillis?: number) {
102+
ow(
103+
options,
104+
ow.object.exactShape({
105+
limit: ow.optional.number,
106+
exclusiveStartKey: ow.optional.string,
107+
collection: ow.optional.string,
108+
prefix: ow.optional.string,
109+
}),
110+
);
111+
112+
const store = await this.get();
113+
114+
let createdPublicKeysUrl = new URL(this._url('items'));
115+
116+
if (store?.urlSigningSecretKey) {
117+
const signature = createStorageContentSignature({
118+
resourceId: store.id,
119+
urlSigningSecretKey: store.urlSigningSecretKey,
120+
expiresInMillis,
121+
});
122+
createdPublicKeysUrl.searchParams.set('signature', signature);
123+
}
124+
125+
createdPublicKeysUrl = applyQueryParamsToUrl(createdPublicKeysUrl, options);
126+
127+
return createdPublicKeysUrl.toString();
128+
}
129+
78130
/**
79131
* Tests whether a record with the given key exists in the key-value store without retrieving its value.
80132
*
@@ -255,6 +307,8 @@ export interface KeyValueStore {
255307
actRunId?: string;
256308
stats?: KeyValueStoreStats;
257309
generalAccess?: STORAGE_GENERAL_ACCESS | null;
310+
urlSigningSecretKey?: string | null;
311+
keysPublicUrl: string;
258312
}
259313

260314
export interface KeyValueStoreStats {

src/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,23 @@ export function asArray<T>(value: T | T[]): T[] {
260260
export type Dictionary<T = unknown> = Record<PropertyKey, T>;
261261

262262
export type DistributiveOptional<T, K extends keyof T> = T extends any ? Omit<T, K> & Partial<Pick<T, K>> : never;
263+
264+
/**
265+
* Adds query parameters to a given URL based on the provided options object.
266+
*/
267+
export function applyQueryParamsToUrl(
268+
url: URL,
269+
options?: Record<string, string | number | boolean | string[] | undefined>,
270+
) {
271+
for (const [key, value] of Object.entries(options ?? {})) {
272+
// skip undefined values
273+
if (value === undefined) continue;
274+
// join array values with a comma
275+
if (Array.isArray(value)) {
276+
url.searchParams.set(key, value.join(','));
277+
continue;
278+
}
279+
url.searchParams.set(key, String(value));
280+
}
281+
return url;
282+
}

test/datasets.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,5 +327,32 @@ describe('Dataset methods', () => {
327327
expect(browserRes).toEqual(res);
328328
validateRequest({}, { datasetId });
329329
});
330+
331+
describe('createItemsPublicUrl()', () => {
332+
it('should include a signature in the URL when the caller has permission to access the signing secret key', async () => {
333+
const datasetId = 'id-with-secret-key';
334+
const res = await client.dataset(datasetId).createItemsPublicUrl();
335+
336+
expect(new URL(res).searchParams.get('signature')).toBeDefined();
337+
});
338+
339+
it('should not include a signature in the URL when the caller lacks permission to access the signing secret key', async () => {
340+
const datasetId = 'some-id';
341+
const res = await client.dataset(datasetId).createItemsPublicUrl();
342+
343+
expect(new URL(res).searchParams.get('signature')).toBeNull();
344+
});
345+
346+
it('includes provided options (e.g., limit and prefix) as query parameters', async () => {
347+
const datasetId = 'id-with-secret-key';
348+
const res = await client.dataset(datasetId).createItemsPublicUrl({ desc: true, limit: 10, offset: 5 });
349+
const itemsPublicUrl = new URL(res);
350+
351+
expect(itemsPublicUrl.searchParams.get('desc')).toBe('true');
352+
expect(itemsPublicUrl.searchParams.get('limit')).toBe('10');
353+
expect(itemsPublicUrl.searchParams.get('offset')).toBe('5');
354+
expect(itemsPublicUrl.searchParams.get('signature')).toBeDefined();
355+
});
356+
});
330357
});
331358
});

test/key_value_stores.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,5 +553,31 @@ describe('Key-Value Store methods', () => {
553553
expect(browserRes).toBeUndefined();
554554
validateRequest({}, { storeId, key });
555555
});
556+
557+
describe('createKeysPublicUrl()', () => {
558+
it('should include a signature in the URL when the caller has permission to access the signing secret key', async () => {
559+
const storeId = 'id-with-secret-key';
560+
const res = await client.keyValueStore(storeId).createKeysPublicUrl();
561+
562+
expect(new URL(res).searchParams.get('signature')).toBeDefined();
563+
});
564+
565+
it('should not include a signature in the URL when the caller lacks permission to access the signing secret key', async () => {
566+
const storeId = 'some-id';
567+
const res = await client.keyValueStore(storeId).createKeysPublicUrl();
568+
569+
expect(new URL(res).searchParams.get('signature')).toBeNull();
570+
});
571+
572+
it('includes provided options (e.g., limit and prefix) as query parameters', async () => {
573+
const storeId = 'id-with-secret-key';
574+
const res = await client.keyValueStore(storeId).createKeysPublicUrl({ limit: 10, prefix: 'prefix' });
575+
const keysPublicUrl = new URL(res);
576+
577+
expect(keysPublicUrl.searchParams.get('limit')).toBe('10');
578+
expect(keysPublicUrl.searchParams.get('prefix')).toBe('prefix');
579+
expect(keysPublicUrl.searchParams.get('signature')).toBeDefined();
580+
});
581+
});
556582
});
557583
});

test/mock_server/routes/datasets.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const datasets = express.Router();
77
const ROUTES = [
88
{ id: 'get-or-create-dataset', method: 'POST', path: '/' },
99
{ id: 'list-datasets', method: 'GET', path: '/' },
10-
{ id: 'get-dataset', method: 'GET', path: '/:datasetId' },
1110
{ id: 'delete-dataset', method: 'DELETE', path: '/:datasetId' },
1211
{ id: 'update-dataset', method: 'PUT', path: '/:datasetId' },
1312
{ id: 'list-items', method: 'GET', path: '/:datasetId/items', type: 'responseJsonMock' },
@@ -17,4 +16,34 @@ const ROUTES = [
1716

1817
addRoutes(datasets, ROUTES);
1918

19+
/**
20+
* GET /datasets/:datasetId
21+
* Returns a specific dataset by its ID.
22+
* If the dataset ID is 'id-with-secret-key', it returns a dataset with a URL signing secret key.
23+
* If the dataset ID is '404', it returns a 404 error with a RECORD_NOT_FOUND type.
24+
* Otherwise, it returns a dataset with an ID of 'get-dataset' (default).
25+
*/
26+
datasets.get('/:datasetId', (req, res) => {
27+
const { datasetId } = req.params;
28+
29+
if (datasetId === 'id-with-secret-key') {
30+
return res.json({
31+
data: {
32+
id: datasetId,
33+
urlSigningSecretKey: 'secret-key-for-testing',
34+
},
35+
});
36+
}
37+
38+
if (datasetId === '404') {
39+
return res.status(404).json({ error: { type: 'record-not-found' } });
40+
}
41+
42+
return res.json({
43+
data: {
44+
id: 'get-dataset',
45+
},
46+
});
47+
});
48+
2049
module.exports = datasets;

test/mock_server/routes/key_value_stores.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const keyValueStores = express.Router();
77
const ROUTES = [
88
{ id: 'list-stores', method: 'GET', path: '/' },
99
{ id: 'get-or-create-store', method: 'POST', path: '/' },
10-
{ id: 'get-store', method: 'GET', path: '/:storeId' },
1110
{ id: 'delete-store', method: 'DELETE', path: '/:storeId' },
1211
{ id: 'update-store', method: 'PUT', path: '/:storeId' },
1312
{ id: 'get-record', method: 'GET', path: '/:storeId/records/:key', type: 'responseJsonMock' },
@@ -24,4 +23,34 @@ const ROUTES = [
2423

2524
addRoutes(keyValueStores, ROUTES);
2625

26+
/**
27+
* GET /key-value-stores/:storeId
28+
* Returns a specific key-value store by its ID.
29+
* If the store ID is 'id-with-secret-key', it returns a store with a URL signing secret key.
30+
* If the store ID is '404', it returns a 404 error with a RECORD_NOT_FOUND type.
31+
* Otherwise, it returns a store with an ID of 'get-store' (default).
32+
*/
33+
keyValueStores.get('/:storeId', (req, res) => {
34+
const { storeId } = req.params;
35+
36+
if (storeId === 'id-with-secret-key') {
37+
return res.json({
38+
data: {
39+
id: storeId,
40+
urlSigningSecretKey: 'secret-key-for-testing',
41+
},
42+
});
43+
}
44+
45+
if (storeId === '404') {
46+
return res.status(404).json({ error: { type: 'record-not-found' } });
47+
}
48+
49+
return res.json({
50+
data: {
51+
id: 'get-store',
52+
},
53+
});
54+
});
55+
2756
module.exports = keyValueStores;

0 commit comments

Comments
 (0)