Skip to content

Commit c5f441b

Browse files
authored
fix: add support for new list request-queue requests parameters (#880)
add support for endpoint parameters and new pagination method added in apify/apify-core#12115 There is a new parameter `filter` for the _request queue -> list requests_ endpoint. To properly support pagination, we also added proper cursor-based pagination, with opaque cursor provided by the server. So this PR switches the pagination internals to use this new way. All of this is done in backwards-compatible manner, the test updates are only necessary to update the server implementation mocks and cases where the existing tests were inspecting some internals.
1 parent dc8f4d6 commit c5f441b

4 files changed

Lines changed: 123 additions & 44 deletions

File tree

src/resource_clients/request_queue.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import type { ApifyRequestConfig } from '../http_client';
1212
import {
1313
cast,
1414
catchNotFoundOrThrow,
15-
PaginationIterator,
15+
mutuallyExclusive,
1616
parseDateFields,
1717
pluckData,
18+
RequestQueuePaginationIterator,
1819
sliceArrayByByteLength,
1920
} from '../utils';
2021

@@ -682,10 +683,14 @@ export class RequestQueueClient extends ResourceClient {
682683
): Promise<RequestQueueClientListRequestsResult> & AsyncIterable<RequestQueueClientRequestSchema> {
683684
ow(
684685
options,
685-
ow.object.exactShape({
686-
limit: ow.optional.number.not.negative,
687-
exclusiveStartId: ow.optional.string,
688-
}),
686+
ow.object
687+
.exactShape({
688+
limit: ow.optional.number.not.negative,
689+
exclusiveStartId: ow.optional.string,
690+
cursor: ow.optional.string,
691+
filter: ow.optional.array.ofType(ow.string.oneOf(['locked', 'pending'])).minLength(1),
692+
})
693+
.validate(mutuallyExclusive('exclusiveStartId', 'cursor')),
689694
);
690695

691696
const getPaginatedList = async (
@@ -695,7 +700,11 @@ export class RequestQueueClient extends ResourceClient {
695700
url: this._url('requests'),
696701
method: 'GET',
697702
timeout: Math.min(MEDIUM_TIMEOUT_MILLIS, this.timeoutMillis ?? Infinity),
698-
params: this._params({ ...rqListOptions, clientKey: this.clientKey }),
703+
params: this._params({
704+
...rqListOptions,
705+
filter: rqListOptions.filter ? rqListOptions.filter.join(',') : undefined,
706+
clientKey: this.clientKey,
707+
}),
699708
});
700709

701710
return cast(parseDateFields(pluckData(response.data)));
@@ -712,10 +721,16 @@ export class RequestQueueClient extends ResourceClient {
712721
// of exhausting all requests we get response with empty items which ends the loop.
713722
while (
714723
currentPage.items.length > 0 && // Continue only if at least some items were returned in the last page.
724+
currentPage.nextCursor && // Continue only if the API returned a cursor for the next page.
715725
(remainingItems === undefined || remainingItems > 0) // Continue only if the limit was not exceeded.
716726
) {
717-
const exclusiveStartId = currentPage.items[currentPage.items.length - 1].id;
718-
const newOptions = { ...options, limit: remainingItems, exclusiveStartId };
727+
const newOptions = {
728+
...options,
729+
limit: remainingItems,
730+
// remove original exclusiveStartId, if there was any, and use cursor-based pagination
731+
exclusiveStartId: undefined,
732+
cursor: currentPage.nextCursor,
733+
};
719734
currentPage = await getPaginatedList(newOptions);
720735
yield* currentPage.items;
721736
if (remainingItems) {
@@ -773,17 +788,29 @@ export class RequestQueueClient extends ResourceClient {
773788
): RequestQueueRequestsAsyncIterable<RequestQueueClientListRequestsResult> {
774789
ow(
775790
options,
776-
ow.object.exactShape({
777-
limit: ow.optional.number.not.negative,
778-
maxPageLimit: ow.optional.number,
779-
exclusiveStartId: ow.optional.string,
780-
}),
791+
ow.object
792+
.exactShape({
793+
limit: ow.optional.number.not.negative,
794+
maxPageLimit: ow.optional.number,
795+
exclusiveStartId: ow.optional.string,
796+
cursor: ow.optional.string,
797+
filter: ow.optional.array.ofType(ow.string.oneOf(['locked', 'pending'])).minLength(1),
798+
})
799+
.validate(mutuallyExclusive('exclusiveStartId', 'cursor')),
781800
);
782-
const { limit, exclusiveStartId, maxPageLimit = DEFAULT_REQUEST_QUEUE_REQUEST_PAGE_LIMIT } = options;
783-
return new PaginationIterator({
784-
getPage: this.listRequests.bind(this),
801+
802+
const {
803+
limit,
804+
exclusiveStartId,
805+
cursor,
806+
filter,
807+
maxPageLimit = DEFAULT_REQUEST_QUEUE_REQUEST_PAGE_LIMIT,
808+
} = options;
809+
return new RequestQueuePaginationIterator({
810+
getPage: async (pageOptions) => this.listRequests({ ...pageOptions, filter }),
785811
limit,
786812
exclusiveStartId,
813+
cursor,
787814
maxPageLimit,
788815
});
789816
}
@@ -860,13 +887,20 @@ export interface RequestQueueClientListHeadResult {
860887
items: RequestQueueClientListItem[];
861888
}
862889

890+
export type RequestQueueListRequestsFilter = 'locked' | 'pending';
891+
863892
/**
864893
* Options for listing all requests in the queue.
865894
*/
866895
export interface RequestQueueClientListRequestsOptions {
867896
limit?: number;
868-
/* Using id of request that does not exist in request queue leads to unpredictable results. */
897+
/**
898+
* Using id of request that does not exist in request queue leads to unpredictable results.
899+
* @deprecated Use `cursor` for pagination instead.
900+
*/
869901
exclusiveStartId?: string;
902+
cursor?: string;
903+
filter?: readonly RequestQueueListRequestsFilter[];
870904
}
871905

872906
/**
@@ -875,15 +909,21 @@ export interface RequestQueueClientListRequestsOptions {
875909
export interface RequestQueueClientPaginateRequestsOptions {
876910
limit?: number;
877911
maxPageLimit?: number;
912+
/** @deprecated Use `cursor` for pagination instead. */
878913
exclusiveStartId?: string;
914+
cursor?: string;
915+
filter?: readonly RequestQueueListRequestsFilter[];
879916
}
880917

881918
/**
882919
* Result of listing all requests in the queue.
883920
*/
884921
export interface RequestQueueClientListRequestsResult {
885922
limit: number;
923+
/** @deprecated Use `cursor` for pagination instead. */
886924
exclusiveStartId?: string;
925+
cursor?: string;
926+
nextCursor?: string;
887927
items: RequestQueueClientRequestSchema[];
888928
}
889929

src/utils.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export function getVersionData(): { version: string } {
186186
/**
187187
* Helper class to create async iterators from paginated list endpoints with exclusive start key.
188188
*/
189-
export class PaginationIterator {
189+
export class RequestQueuePaginationIterator {
190190
private readonly maxPageLimit: number;
191191

192192
private readonly getPage: (
@@ -196,33 +196,41 @@ export class PaginationIterator {
196196
private readonly limit?: number;
197197

198198
private readonly exclusiveStartId?: string;
199+
private readonly cursor?: string;
199200

200-
constructor(options: PaginationIteratorOptions) {
201+
constructor(options: RequestQueuePaginationIteratorOptions) {
201202
this.maxPageLimit = options.maxPageLimit;
202203
this.limit = options.limit;
203204
this.exclusiveStartId = options.exclusiveStartId;
205+
this.cursor = options.cursor;
204206
this.getPage = options.getPage;
205207
}
206208

207209
async *[Symbol.asyncIterator](): AsyncIterator<RequestQueueClientListRequestsResult> {
208-
let nextPageExclusiveStartId;
210+
let nextCursor = this.cursor;
211+
// allow using exclusiveStartId for the first page, but then we'll delete it to avoid using it for any later page
212+
let nextExclusiveStartId = this.exclusiveStartId;
213+
209214
let iterateItemCount = 0;
210215
while (true) {
211216
const pageLimit = this.limit
212217
? Math.min(this.maxPageLimit, this.limit - iterateItemCount)
213218
: this.maxPageLimit;
214-
const pageExclusiveStartId = nextPageExclusiveStartId || this.exclusiveStartId;
219+
215220
const page: RequestQueueClientListRequestsResult = await this.getPage({
216221
limit: pageLimit,
217-
exclusiveStartId: pageExclusiveStartId,
222+
cursor: nextCursor,
223+
exclusiveStartId: nextExclusiveStartId,
218224
});
219225
// There are no more pages to iterate
220226
if (page.items.length === 0) return;
221227
yield page;
222228
iterateItemCount += page.items.length;
223229
// Limit reached stopping to iterate
224-
if (this.limit && iterateItemCount >= this.limit) return;
225-
nextPageExclusiveStartId = page.items[page.items.length - 1].id;
230+
if ((this.limit && iterateItemCount >= this.limit) || !page.nextCursor) return;
231+
232+
nextCursor = page.nextCursor;
233+
nextExclusiveStartId = undefined; // see comment above - delete it for any page after the first one, and paginate with cursor
226234
}
227235
}
228236
}
@@ -235,11 +243,12 @@ declare global {
235243
/**
236244
* Options for creating a pagination iterator.
237245
*/
238-
export interface PaginationIteratorOptions {
246+
export interface RequestQueuePaginationIteratorOptions {
239247
maxPageLimit: number;
240248
getPage: (opts: RequestQueueClientListRequestsOptions) => Promise<RequestQueueClientListRequestsResult>;
241249
limit?: number;
242250
exclusiveStartId?: string;
251+
cursor?: string;
243252
}
244253

245254
/**
@@ -345,3 +354,13 @@ export function applyQueryParamsToUrl(
345354
}
346355
return url;
347356
}
357+
358+
export const mutuallyExclusive =
359+
<T, K extends keyof T>(...keys: K[]) =>
360+
(value: T) => {
361+
const presentKeys = keys.filter((key) => typeof value[key] !== 'undefined');
362+
return {
363+
validator: presentKeys.length <= 1,
364+
message: `At most one of the following fields is allowed: ${keys.join(', ')}`,
365+
};
366+
};

test/pagination.test.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,11 @@ describe('RequestQueueClient.listKeys as async iterable', () => {
359359
userDefinedOptions: { exclusiveStartId: '1000' },
360360
expectedItems: range(1001, 2500),
361361
},
362+
{
363+
testName: 'cursor',
364+
userDefinedOptions: { cursor: 'cursor:1000' },
365+
expectedItems: range(1000, 2500),
366+
},
362367
];
363368

364369
const testCases = generateTestCases(
@@ -376,17 +381,23 @@ describe('RequestQueueClient.listKeys as async iterable', () => {
376381
// There are 2500 items in the collection.
377382
// Items are simple objects with incrementing attributes for easy verification.
378383

379-
const exclusiveStartId = request.params.exclusiveStartId ? request.params.exclusiveStartId : null;
380384
const limit = request.params.limit ? request.params.limit : 0;
381385

382386
if (limit < 0) {
383387
throw new Error('Limit must be non-negative');
384388
}
385389

386-
const lowerIndex = Math.min(
387-
request.params.exclusiveStartId ? Number(request.params.exclusiveStartId) + 1 : 0,
388-
totalItems,
389-
);
390+
if (request.params.exclusiveStartId && request.params.cursor) {
391+
throw new Error('exclusiveStartId and cursor cannot be used together');
392+
}
393+
let effectiveStartIndex = 0;
394+
if (request.params.exclusiveStartId) {
395+
effectiveStartIndex = Number(request.params.exclusiveStartId) + 1;
396+
} else if (request.params.cursor) {
397+
effectiveStartIndex = Number(request.params.cursor.split(':')[1]);
398+
}
399+
400+
const lowerIndex = Math.min(effectiveStartIndex, totalItems);
390401
const upperIndex = Math.min(lowerIndex + (limit || totalItems), totalItems, lowerIndex + maxItemsPerPage);
391402
const items = range(lowerIndex, upperIndex);
392403

@@ -396,7 +407,9 @@ describe('RequestQueueClient.listKeys as async iterable', () => {
396407
items,
397408
count: items.length,
398409
limit: limit || maxItemsPerPage,
399-
exclusiveStartId,
410+
exclusiveStartId: request.params.exclusiveStartId,
411+
cursor: request.params.cursor,
412+
nextCursor: items.length < maxItemsPerPage ? undefined : `cursor:${upperIndex}`,
400413
},
401414
},
402415
};
@@ -410,11 +423,7 @@ describe('RequestQueueClient.listKeys as async iterable', () => {
410423
items.push(page);
411424
}
412425

413-
let expectedAPIcalls = Math.max(Math.ceil(expectedItems.length / maxItemsPerPage), 1);
414-
if (userDefinedOptions.limit === undefined || userDefinedOptions.limit > totalItems) {
415-
// One extra call to confirm there are no more items due RQ API design.
416-
expectedAPIcalls += 1;
417-
}
426+
const expectedAPIcalls = Math.max(Math.ceil(expectedItems.length / maxItemsPerPage), 1);
418427

419428
expect(items).toEqual(expectedItems);
420429
expect(mockedClient).toHaveBeenCalledTimes(expectedAPIcalls);

test/request_queues.test.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AddressInfo } from 'node:net';
22

3-
import type { Dictionary } from 'apify-client';
3+
import type { Dictionary, RequestQueueClientListRequestsOptions } from 'apify-client';
44
import { ApifyClient } from 'apify-client';
55
import type { Request } from 'express';
66
import type { Page } from 'puppeteer';
@@ -525,20 +525,29 @@ describe('Request Queue methods', () => {
525525
validateRequest({ query: {}, params: { queueId, requestId } });
526526
});
527527

528-
test('listRequests() works', async () => {
528+
test.each([
529+
[undefined, undefined],
530+
[['pending'], 'pending'],
531+
[['pending', 'locked'], 'pending,locked'],
532+
] as const)('listRequests() works', async (filter, filterInQuery) => {
529533
const queueId = 'some-id';
530-
const options = { limit: 5, exclusiveStartId: '123' };
534+
const options = { limit: 5, exclusiveStartId: '123' } as RequestQueueClientListRequestsOptions;
535+
const queryForValidation = { limit: '5', exclusiveStartId: '123' } as Record<string, string>;
536+
if (filter) {
537+
options.filter = filter;
538+
queryForValidation.filter = filterInQuery;
539+
}
531540

532541
const res = await client.requestQueue(queueId).listRequests(options);
533-
validateRequest({ query: options, params: { queueId }, endpointId: 'list-requests' });
542+
validateRequest({ query: queryForValidation, params: { queueId }, endpointId: 'list-requests' });
534543

535544
const browserRes = await page.evaluate(
536545
(id, opts) => client.requestQueue(id).listRequests(opts),
537546
queueId,
538547
options,
539548
);
540549
expect(browserRes).toEqual(res);
541-
validateRequest({ query: options, params: { queueId } });
550+
validateRequest({ query: queryForValidation, params: { queueId } });
542551
});
543552

544553
test('paginateRequests() works', async () => {
@@ -551,6 +560,8 @@ describe('Request Queue methods', () => {
551560
body: {
552561
data: {
553562
items,
563+
nextCursor:
564+
items.length > 0 ? `the-request-after-${items[items.length - 1].id}` : undefined,
554565
},
555566
},
556567
});
@@ -559,17 +570,17 @@ describe('Request Queue methods', () => {
559570
const pagination = client.requestQueue(queueId).paginateRequests({ maxPageLimit });
560571
// Mock API call for the first page
561572
let expectedItemsInPage = requests.splice(0, maxPageLimit);
562-
let expectedExclusiveStartId;
573+
let expectedCursor;
563574
mockResponse(expectedItemsInPage);
564575
for await (const { items } of pagination) {
565576
expect(items).toEqual(expectedItemsInPage);
566577
// Validate the request for the current iteration page
567578
validateRequest({
568-
query: { exclusiveStartId: expectedExclusiveStartId, limit: maxPageLimit },
579+
query: { cursor: expectedCursor, limit: maxPageLimit },
569580
params: { queueId },
570581
});
571582
// Prepare expectations and mock request for the next iteration page
572-
expectedExclusiveStartId = items[items.length - 1].id;
583+
expectedCursor = `the-request-after-${items[items.length - 1].id}`;
573584
expectedItemsInPage = requests.splice(0, maxPageLimit);
574585
mockResponse(expectedItemsInPage);
575586
}

0 commit comments

Comments
 (0)