Skip to content

Commit 68ed7a3

Browse files
committed
Generalized form. Applied only to StoreCollectionClient
1 parent 8c38f94 commit 68ed7a3

File tree

5 files changed

+212
-80
lines changed

5 files changed

+212
-80
lines changed

src/base/resource_collection_client.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseDateFields, pluckData } from '../utils';
1+
import type { PaginatedResponse, PaginationOptions , parseDateFields, pluckData } from '../utils';
22
import { ApiClient } from './api_client';
33

44
/**
@@ -18,6 +18,43 @@ export class ResourceCollectionClient extends ApiClient {
1818
return parseDateFields(pluckData(response.data)) as R;
1919
}
2020

21+
/**
22+
* Returns async iterator to paginate through all pages and first page of results is returned immediately as well.
23+
* @private
24+
*/
25+
protected async _getIterablePagination<T extends PaginationOptions, R extends PaginatedResponse>(
26+
options: T = {} as T,
27+
): Promise<R & AsyncIterable<R>> {
28+
const getPaginatedList = this._list.bind(this);
29+
30+
let currentPage = await getPaginatedList<T, R>(options);
31+
32+
return {
33+
...currentPage,
34+
async *[Symbol.asyncIterator]() {
35+
yield currentPage;
36+
let itemsFetched = currentPage.items.length;
37+
let currentLimit = options.limit !== undefined ? options.limit - itemsFetched : undefined;
38+
let currentOffset = options.offset ?? 0 + itemsFetched;
39+
const maxRelevantItems =
40+
currentPage.total === undefined ? undefined : currentPage.total - (options.offset || 0);
41+
42+
while (
43+
currentPage.items.length > 0 && // Some items were returned in last page
44+
(currentLimit === undefined || currentLimit > 0) && // User defined a limit, and we have not yet exhausted it
45+
(maxRelevantItems === undefined || maxRelevantItems > itemsFetched) // We know total and we did not get it yet
46+
) {
47+
const newOptions = { ...options, limit: currentLimit, offset: currentOffset };
48+
currentPage = await getPaginatedList<T, R>(newOptions);
49+
yield currentPage;
50+
itemsFetched += currentPage.items.length;
51+
currentLimit = options.limit !== undefined ? options.limit - itemsFetched : undefined;
52+
currentOffset = options.offset ?? 0 + itemsFetched;
53+
}
54+
},
55+
};
56+
}
57+
2158
protected async _create<D, R>(resource: D): Promise<R> {
2259
const response = await this.httpClient.call({
2360
url: this._url(),

src/resource_clients/dataset.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
SMALL_TIMEOUT_MILLIS,
1313
} from '../base/resource_client';
1414
import type { ApifyRequestConfig, ApifyResponse } from '../http_client';
15-
import { IterablePaginatedList, PaginatedList } from "../utils";
15+
import type { PaginatedList } from '../utils';
1616
import { applyQueryParamsToUrl, cast, catchNotFoundOrThrow, pluckData } from '../utils';
1717

1818
export class DatasetClient<
@@ -54,7 +54,7 @@ export class DatasetClient<
5454
/**
5555
* https://docs.apify.com/api/v2#/reference/datasets/item-collection/get-items
5656
*/
57-
async listItems(options: DatasetClientListItemOptions = {}): Promise<IterablePaginatedList<Data>> {
57+
async listItems(options: DatasetClientListItemOptions = {}): Promise<PaginatedList<Data>> {
5858
ow(
5959
options,
6060
ow.object.exactShape({
@@ -73,36 +73,14 @@ export class DatasetClient<
7373
}),
7474
);
7575

76-
// TODO: Should empty limit return all, or current behavior, which is max per request 1000?
77-
let chunksIterated = 0;
78-
const self=this;
79-
const firstResponse = await self.httpClient.call({
76+
const response = await this.httpClient.call({
8077
url: this._url('items'),
8178
method: 'GET',
8279
params: this._params(options),
8380
timeout: DEFAULT_TIMEOUT_MILLIS,
8481
});
8582

86-
let currentPage = this._createPaginationList(firstResponse, options.desc ?? false);
87-
88-
return {
89-
...currentPage,
90-
async *[Symbol.asyncIterator](){
91-
while (currentPage.items.length!=0 || chunksIterated===0) {
92-
if (chunksIterated > 0) {
93-
const response = await self.httpClient.call({
94-
url: self._url('items'),
95-
method: 'GET',
96-
params: self._params(options),
97-
timeout: DEFAULT_TIMEOUT_MILLIS,
98-
});
99-
currentPage = self._createPaginationList(response, options.desc ?? false);
100-
}
101-
chunksIterated++;
102-
yield currentPage;
103-
}
104-
}
105-
};
83+
return this._createPaginationList(response, options.desc ?? false);
10684
}
10785

10886
/**

src/resource_clients/store_collection.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import ow from 'ow';
22

33
import type { ApiClientSubResourceOptions } from '../base/api_client';
44
import { ResourceCollectionClient } from '../base/resource_collection_client';
5-
import type { IterablePaginatedList , PaginatedList} from "../utils";
5+
import type { IterablePaginatedList } from '../utils';
66
import type { ActorStats } from './actor';
77

88
export class StoreCollectionClient extends ResourceCollectionClient {
@@ -32,32 +32,8 @@ export class StoreCollectionClient extends ResourceCollectionClient {
3232
pricingModel: ow.optional.string,
3333
}),
3434
);
35-
const getPaginatedList = this._list.bind(this);
3635

37-
let currentPage = await getPaginatedList<StoreCollectionListOptions, PaginatedList<ActorStoreList>>(options)
38-
39-
40-
41-
return {
42-
...currentPage,
43-
async *[Symbol.asyncIterator](){
44-
yield currentPage;
45-
let itemsFetched = currentPage.items.length;
46-
let currentLimit= options.limit !== undefined ? options.limit-itemsFetched: undefined;
47-
let currentOffset= options.offset ?? 0 + itemsFetched;
48-
49-
while (currentPage.items.length>0 && (currentLimit === undefined || currentLimit>0)) {
50-
51-
const newOptions = { ...options, limit: currentLimit, offset: currentOffset};
52-
currentPage = await getPaginatedList<StoreCollectionListOptions, PaginatedList<ActorStoreList>>(newOptions)
53-
yield currentPage;
54-
itemsFetched += currentPage.items.length
55-
currentLimit= options.limit !== undefined ? options.limit-itemsFetched: undefined;
56-
currentOffset= options.offset ?? 0 + itemsFetched;
57-
58-
}
59-
}
60-
};
36+
return this._getIterablePagination(options);
6137
}
6238
}
6339

src/utils.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -233,32 +233,36 @@ export interface PaginationIteratorOptions {
233233
exclusiveStartId?: string;
234234
}
235235

236-
export interface PaginatedList<Data>{
236+
export interface PaginationOptions {
237+
/** Position of the first returned entry. */
238+
offset?: number;
239+
/** Maximum number of entries requested. */
240+
limit?: number;
241+
}
242+
243+
export interface PaginatedResponse {
244+
/** Total count of entries. */
245+
total?: number;
246+
/** Entries. */
247+
items: unknown[];
248+
}
249+
250+
export interface PaginatedList<Data> {
237251
/** Total count of entries in the dataset. */
238252
total: number;
239253
/** Count of dataset entries returned in this set. */
240254
count: number;
241-
/** Position of the first returned entry in the dataset. */
242-
offset: number;
243-
/** Maximum number of dataset entries requested. */
244-
limit: number;
245255
/** Should the results be in descending order. */
246256
desc: boolean;
247-
/** Dataset entries based on chosen format parameter. */
248-
items: Data[];
249-
}
250-
251-
export interface IterablePagination<Data>{
252-
/** Position of the first returned entry in the dataset. */
257+
/** Position of the first returned entry. */
253258
offset: number;
254259
/** Maximum number of dataset entries requested. */
255260
limit: number;
256-
/** Should the results be in descending order. */
261+
/** Dataset entries based on chosen format parameter. */
257262
items: Data[];
258263
}
259264

260-
export interface IterablePaginatedList<Data> extends PaginatedList<Data>, AsyncIterable<PaginatedList<Data>>{
261-
}
265+
export interface IterablePaginatedList<Data> extends PaginatedList<Data>, AsyncIterable<PaginatedList<Data>> {}
262266

263267
export function cast<T>(input: unknown): T {
264268
return input as T;

test/store.test.ts

Lines changed: 150 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import { setTimeout } from 'node:timers/promises';
2+
3+
import c from 'ansi-colors';
14
import type { StoreCollectionListOptions } from 'apify-client';
2-
import { ApifyClient } from 'apify-client';
5+
import { ApifyClient, LoggerActorRedirect } from 'apify-client';
6+
import express from 'express';
7+
8+
import { LEVELS, Log } from '@apify/log';
39

10+
import type { ApifyResponse } from '../src/http_client';
411
import { Browser, DEFAULT_OPTIONS, validateRequest } from './_helper';
5-
import { mockServer } from './mock_server/server';
12+
import { createDefaultApp, mockServer } from './mock_server/server';
13+
import { MOCKED_ACTOR_LOGS_PROCESSED, StatusGenerator } from './mock_server/test_utils';
614

715
describe('Store', () => {
816
let baseUrl: string | undefined;
@@ -33,17 +41,17 @@ describe('Store', () => {
3341
page.close().catch(() => {});
3442
});
3543

36-
test('list() works', async () => {
37-
const opts = {
38-
limit: 5,
39-
offset: 3,
40-
search: 'my search',
41-
sortBy: 'my sort',
42-
category: 'my category',
43-
username: 'my username',
44-
pricingModel: 'my pricing model',
45-
};
44+
const opts = {
45+
limit: 5,
46+
offset: 3,
47+
search: 'my search',
48+
sortBy: 'my sort',
49+
category: 'my category',
50+
username: 'my username',
51+
pricingModel: 'my pricing model',
52+
};
4653

54+
test('list() works', async () => {
4755
const res: any = client && (await client.store().list(opts));
4856
expect(res.id).toEqual('store-list');
4957
validateRequest(opts);
@@ -53,7 +61,136 @@ describe('Store', () => {
5361
opts,
5462
);
5563
expect(browserRes.id).toEqual('store-list');
56-
expect(browserRes).toEqual(res);
64+
const { [Symbol.asyncIterator]: _, ...expectedResponse } = res;
65+
expect(browserRes).toEqual(expectedResponse);
5766
validateRequest(opts);
5867
});
5968
});
69+
70+
describe('actor.store.list as async iterable', () => {
71+
// Test using store().list() as an async iterable
72+
const client: ApifyClient = new ApifyClient();
73+
74+
const createItems = (count: number) => {
75+
return new Array(count).fill('some actor details');
76+
};
77+
78+
const exampleResponseData = {
79+
total: 2500,
80+
count: 0,
81+
offset: 0,
82+
limit: 0,
83+
desc: false,
84+
items: createItems(1000),
85+
};
86+
87+
const testCases = [
88+
{
89+
testName: 'Known total items, no offset, no limit',
90+
options: {},
91+
responseDataOverrides: [
92+
{ count: 1000, limit: 1000 },
93+
{ count: 1000, limit: 1000, offset: 1000 },
94+
{ count: 500, limit: 1000, offset: 2000, items: createItems(500) },
95+
],
96+
expectedItems: 2500,
97+
},
98+
{
99+
testName: 'Known total items, user offset, no limit',
100+
options: { offset: 1000 },
101+
responseDataOverrides: [
102+
{ count: 1000, limit: 1000, offset: 1000 },
103+
{ count: 500, limit: 1000, offset: 2000, items: createItems(500) },
104+
],
105+
expectedItems: 1500,
106+
},
107+
{
108+
testName: 'Known total items, no offset, user limit',
109+
options: { limit: 1100 },
110+
responseDataOverrides: [
111+
{ count: 1000, limit: 1000 },
112+
{ count: 100, limit: 100, offset: 1000, items: createItems(100) },
113+
],
114+
expectedItems: 1100,
115+
},
116+
{
117+
testName: 'Known total items, user offset, user limit',
118+
options: { offset: 1000, limit: 1100 },
119+
responseDataOverrides: [
120+
{ count: 1000, limit: 1000, offset: 1000 },
121+
{ count: 100, limit: 100, offset: 2000, items: createItems(100) },
122+
],
123+
expectedItems: 1100,
124+
},
125+
{
126+
testName: 'Unknown total items, no offset, no limit',
127+
options: {},
128+
responseDataOverrides: [
129+
{ total: undefined, count: 1000, limit: 1000 },
130+
{ total: undefined, count: 1000, limit: 1000, offset: 1000 },
131+
{ total: undefined, count: 500, limit: 1000, offset: 2000, items: createItems(500) },
132+
{ total: undefined, count: 0, limit: 1000, offset: 2500, items: [] }, // In this case, iterator had to try as it does not know if there is more or not and there is no user limit.
133+
],
134+
expectedItems: 2500,
135+
},
136+
{
137+
testName: 'Unknown total items, user offset, no limit',
138+
options: { offset: 1000 },
139+
responseDataOverrides: [
140+
{ total: undefined, count: 1000, limit: 1000, offset: 1000 },
141+
{ total: undefined, count: 500, limit: 1000, offset: 2000, items: createItems(500) },
142+
{ total: undefined, count: 0, limit: 1000, offset: 2500, items: [] }, // In this case, iterator had to try as it does not know if there is more or not and there is no user limit.
143+
],
144+
expectedItems: 1500,
145+
},
146+
{
147+
testName: 'Unknown total items, no offset, user limit',
148+
options: { limit: 1100 },
149+
responseDataOverrides: [
150+
{ total: undefined, count: 1000, limit: 1000 },
151+
{ total: undefined, count: 100, limit: 100, offset: 1000, items: createItems(100) },
152+
],
153+
expectedItems: 1100,
154+
},
155+
{
156+
testName: 'Unknown total items, user offset, user limit',
157+
options: { offset: 1000, limit: 1100 },
158+
responseDataOverrides: [
159+
{ total: undefined, count: 1000, limit: 1000, offset: 1000 },
160+
{ total: undefined, count: 100, limit: 100, offset: 2000, items: createItems(100) },
161+
],
162+
expectedItems: 1100,
163+
},
164+
];
165+
166+
test.each(testCases)('$testName', async ({ options, responseDataOverrides, expectedItems }) => {
167+
// Simulate 2500 actors in store and 8 possible combinations of user options and API responses.
168+
169+
function* mockedResponsesGenerator() {
170+
for (const responseDataOverride of responseDataOverrides) {
171+
yield { data: { data: { ...exampleResponseData, ...responseDataOverride } } } as ApifyResponse;
172+
}
173+
}
174+
175+
const mockedResponses = mockedResponsesGenerator();
176+
177+
const storeClient = client.store();
178+
const mockedClient = jest.spyOn(storeClient.httpClient, 'call').mockImplementation(async () => {
179+
const next = mockedResponses.next();
180+
if (next.done) {
181+
// Return a default or dummy ApifyResponse here
182+
return { data: {} } as ApifyResponse<unknown>;
183+
}
184+
return next.value;
185+
});
186+
187+
const pages = await client.store().list(options);
188+
189+
const totalItems: any[] = [];
190+
for await (const page of pages) {
191+
totalItems.push(...page.items);
192+
}
193+
mockedClient.mockRestore();
194+
expect(totalItems.length).toBe(expectedItems);
195+
});
196+
});

0 commit comments

Comments
 (0)