Skip to content

Commit 761c7c6

Browse files
Experimental pagination feature (sindresorhus#833)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent 68fc7ab commit 761c7c6

File tree

7 files changed

+448
-11
lines changed

7 files changed

+448
-11
lines changed

package.json

+6-4
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@sindresorhus/is": "^1.0.0",
4545
"@szmarczak/http-timer": "^4.0.0",
4646
"@types/cacheable-request": "^6.0.1",
47-
"cacheable-lookup": "^1.0.0",
47+
"cacheable-lookup": "^2.0.0",
4848
"cacheable-request": "^7.0.1",
4949
"decompress-response": "^5.0.0",
5050
"duplexer3": "^0.1.4",
@@ -57,7 +57,7 @@
5757
"type-fest": "^0.9.0"
5858
},
5959
"devDependencies": {
60-
"@ava/typescript": "^1.0.0",
60+
"@ava/typescript": "^1.1.0",
6161
"@sindresorhus/tsconfig": "^0.7.0",
6262
"@types/duplexer3": "^0.1.0",
6363
"@types/express": "^4.17.2",
@@ -68,7 +68,7 @@
6868
"@types/tough-cookie": "^2.3.5",
6969
"@typescript-eslint/eslint-plugin": "^2.17.0",
7070
"@typescript-eslint/parser": "^2.17.0",
71-
"ava": "^3.1.0",
71+
"ava": "^3.2.0",
7272
"coveralls": "^3.0.4",
7373
"create-test-server": "^3.0.1",
7474
"del-cli": "^3.0.0",
@@ -101,7 +101,6 @@
101101
"files": [
102102
"test/*"
103103
],
104-
"concurrency": 4,
105104
"timeout": "1m",
106105
"typescript": {
107106
"rewritePaths": {
@@ -112,6 +111,9 @@
112111
"nyc": {
113112
"extension": [
114113
".ts"
114+
],
115+
"exclude": [
116+
"**/test/**"
115117
]
116118
},
117119
"xo": {

readme.md

+73-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ For browser usage, we recommend [Ky](https://github.com/sindresorhus/ky) by the
2626

2727
## Highlights
2828

29-
- [Promise & stream API](#api)
29+
- [Promise API](#api)
30+
- [Stream API](#streams)
31+
- [Pagination API (experimental)](#pagination)
3032
- [Request cancelation](#aborting-the-request)
3133
- [RFC compliant caching](#cache-adapters)
3234
- [Follows redirects](#followredirect)
@@ -635,6 +637,49 @@ got('https://api.github.com/some-endpoint', {
635637
});
636638
```
637639

640+
##### \_pagination
641+
642+
Type: `object`
643+
644+
**Note:** This feature is marked as experimental as we're [looking for feedback](https://github.com/sindresorhus/got/issues/1052) on the API and how it works. The feature itself is stable, but the API may change based on feedback. So if you decide to try it out, we suggest locking down the `got` dependency semver range or use a lockfile.
645+
646+
###### \_pagination.transform
647+
648+
Type: `Function`\
649+
Default: `response => JSON.parse(response.body)`
650+
651+
A function that transform [`Response`](#response) into an array of items. This is where you should do the parsing.
652+
653+
###### \_pagination.paginate
654+
655+
Type: `Function`\
656+
Default: [`Link` header logic](source/index.ts)
657+
658+
A function that returns an object representing Got options pointing to the next page. If there are no more pages, `false` should be returned.
659+
660+
###### \_pagination.filter
661+
662+
Type: `Function`\
663+
Default: `(item, allItems) => true`
664+
665+
Checks whether the item should be emitted or not.
666+
667+
###### \_pagination.shouldContinue
668+
669+
Type: `Function`\
670+
Default: `(item, allItems) => true`
671+
672+
Checks whether the pagination should continue.
673+
674+
For example, if you need to stop **before** emitting an entry with some flag, you should use `(item, allItems) => !item.flag`. If you want to stop **after** emitting the entry, you should use `(item, allItems) => allItems.some(entry => entry.flag)` instead.
675+
676+
###### \_pagination.countLimit
677+
678+
Type: `number`\
679+
Default: `Infinity`
680+
681+
The maximum amount of items that should be emitted.
682+
638683
#### Response
639684

640685
The response object will typically be a [Node.js HTTP response stream](https://nodejs.org/api/http.html#http_class_http_incomingmessage), however, if returned from the cache it will be a [response-like object](https://github.com/lukechilds/responselike) which behaves in the same way.
@@ -785,6 +830,30 @@ If it's not possible to retrieve the body size (can happen when streaming), `tot
785830

786831
The `error` event emitted in case of a protocol error (like `ENOTFOUND` etc.) or status error (4xx or 5xx). The second argument is the body of the server response in case of status error. The third argument is a response object.
787832

833+
#### Pagination
834+
835+
#### got.paginate(url, options?)
836+
837+
Returns an async iterator:
838+
839+
```js
840+
(async () => {
841+
const countLimit = 10;
842+
843+
const pagination = got.paginate('https://api.github.com/repos/sindresorhus/got/commits', {
844+
_pagination: {countLimit}
845+
});
846+
847+
console.log(`Printing latest ${countLimit} Got commits (newest to oldest):`);
848+
849+
for await (const commitData of pagination) {
850+
console.log(commitData.commit.message);
851+
}
852+
})();
853+
```
854+
855+
See [`options._pagination`](#_pagination) for more pagination options.
856+
788857
#### got.get(url, options?)
789858
#### got.post(url, options?)
790859
#### got.put(url, options?)
@@ -1462,6 +1531,7 @@ Some of the Got features may not work properly. See [#899](https://github.com/si
14621531
| Browser support | :x: | :x: | :heavy_check_mark:\* | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
14631532
| Promise API | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
14641533
| Stream API | :heavy_check_mark: | :heavy_check_mark: | Node.js only | :x: | :x: | :heavy_check_mark: |
1534+
| Pagination API | :sparkle: | :x: | :x: | :x: | :x: | :x: |
14651535
| Request cancelation | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
14661536
| RFC compliant caching | :heavy_check_mark: | :x: | :x: | :x: | :x: | :x: |
14671537
| Cookies (out-of-box) | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: | :x: | :x: |
@@ -1488,7 +1558,8 @@ Some of the Got features may not work properly. See [#899](https://github.com/si
14881558
\* It's almost API compatible with the browser `fetch` API.\
14891559
\*\* Need to switch the protocol manually. Doesn't accept PUSH streams and doesn't reuse HTTP/2 sessions.\
14901560
\*\*\* Currently, only `DownloadProgress` event is supported, `UploadProgress` event is not supported.\
1491-
:grey_question: Experimental support.
1561+
:sparkle: Almost-stable feature, but the API may change. Don't hestitate to try it out!\
1562+
:grey_question: Feature in early stage of development. Very experimental.
14921563

14931564
<!-- GITHUB -->
14941565
[k0]: https://github.com/sindresorhus/ky

source/create.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Merge} from 'type-fest';
2+
import is from '@sindresorhus/is';
23
import asPromise, {createRejection} from './as-promise';
34
import asStream, {ProxyStream} from './as-stream';
45
import * as errors from './errors';
@@ -13,7 +14,8 @@ import {
1314
NormalizedOptions,
1415
Options,
1516
Response,
16-
URLOrOptions
17+
URLOrOptions,
18+
PaginationOptions
1719
} from './types';
1820

1921
export type HTTPAlias =
@@ -48,17 +50,25 @@ export interface GotRequestMethod {
4850
(url: string | OptionsOfTextResponseBody, options?: OptionsOfTextResponseBody): CancelableRequest<Response<string>>;
4951
<T>(url: string | OptionsOfJSONResponseBody, options?: OptionsOfJSONResponseBody): CancelableRequest<Response<T>>;
5052
(url: string | OptionsOfBufferResponseBody, options?: OptionsOfBufferResponseBody): CancelableRequest<Response<Buffer>>;
53+
5154
// `resolveBodyOnly` usage
5255
<T = string>(url: string | Merge<OptionsOfDefaultResponseBody, ResponseBodyOnly>, options?: Merge<OptionsOfDefaultResponseBody, ResponseBodyOnly>): CancelableRequest<T>;
5356
(url: string | Merge<OptionsOfTextResponseBody, ResponseBodyOnly>, options?: Merge<OptionsOfTextResponseBody, ResponseBodyOnly>): CancelableRequest<string>;
5457
<T>(url: string | Merge<OptionsOfJSONResponseBody, ResponseBodyOnly>, options?: Merge<OptionsOfJSONResponseBody, ResponseBodyOnly>): CancelableRequest<T>;
5558
(url: string | Merge<OptionsOfBufferResponseBody, ResponseBodyOnly>, options?: Merge<OptionsOfBufferResponseBody, ResponseBodyOnly>): CancelableRequest<Buffer>;
59+
5660
// `asStream` usage
5761
<T>(url: string | Merge<Options, {isStream: true}>, options?: Merge<Options, {isStream: true}>): ProxyStream<T>;
5862
}
5963

64+
export interface GotPaginate {
65+
<T>(url: URLOrOptions & PaginationOptions<T>, options?: Options & PaginationOptions<T>): AsyncIterableIterator<T>;
66+
all<T>(url: URLOrOptions & PaginationOptions<T>, options?: Options & PaginationOptions<T>): Promise<T[]>;
67+
}
68+
6069
export interface Got extends Record<HTTPAlias, GotRequestMethod>, GotRequestMethod {
6170
stream: GotStream;
71+
paginate: GotPaginate;
6272
defaults: Defaults;
6373
GotError: typeof errors.GotError;
6474
CacheError: typeof errors.CacheError;
@@ -178,6 +188,64 @@ const create = (defaults: Defaults): Got => {
178188
got.stream[method] = (url, options) => got.stream(url, {...options, method});
179189
}
180190

191+
// @ts-ignore The missing property is added below
192+
got.paginate = async function * <T>(url: URLOrOptions & PaginationOptions<T>, options?: Options & PaginationOptions<T>) {
193+
let normalizedOptions = normalizeArguments(url, options, defaults) as NormalizedOptions & PaginationOptions<T>;
194+
195+
const pagination = normalizedOptions._pagination!;
196+
197+
if (!is.object(pagination)) {
198+
throw new Error('`options._pagination` must be implemented');
199+
}
200+
201+
const all: T[] = [];
202+
203+
while (true) {
204+
// @ts-ignore See https://github.com/sindresorhus/got/issues/954
205+
// eslint-disable-next-line no-await-in-loop
206+
const result = await got(normalizedOptions);
207+
208+
// eslint-disable-next-line no-await-in-loop
209+
const parsed = await pagination.transform!(result);
210+
211+
for (const item of parsed) {
212+
if (pagination.filter!(item, all)) {
213+
if (!pagination.shouldContinue!(item, all)) {
214+
return;
215+
}
216+
217+
yield item;
218+
219+
all.push(item as T);
220+
221+
if (all.length === pagination.countLimit) {
222+
return;
223+
}
224+
}
225+
}
226+
227+
const optionsToMerge = pagination.paginate!(result);
228+
229+
if (optionsToMerge === false) {
230+
return;
231+
}
232+
233+
if (optionsToMerge !== undefined) {
234+
normalizedOptions = normalizeArguments(normalizedOptions, optionsToMerge) as NormalizedOptions & PaginationOptions<T>;
235+
}
236+
}
237+
};
238+
239+
got.paginate.all = async <T>(url: URLOrOptions & PaginationOptions<T>, options?: Options & PaginationOptions<T>) => {
240+
const results: T[] = [];
241+
242+
for await (const item of got.paginate<T>(url, options)) {
243+
results.push(item);
244+
}
245+
246+
return results;
247+
};
248+
181249
Object.assign(got, {...errors, mergeOptions});
182250
Object.defineProperty(got, 'defaults', {
183251
value: defaults.mutableDefaults ? defaults : deepFreeze(defaults),

source/index.ts

+39-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import {URL} from 'url';
12
import create, {defaultHandler} from './create';
2-
import {Defaults} from './types';
3+
import {Defaults, Response, GotOptions} from './types';
34

45
const defaults: Defaults = {
56
options: {
@@ -64,7 +65,43 @@ const defaults: Defaults = {
6465
prefixUrl: '',
6566
methodRewriting: true,
6667
ignoreInvalidCookies: false,
67-
context: {}
68+
context: {},
69+
_pagination: {
70+
transform: (response: Response) => {
71+
return JSON.parse(response.body as string);
72+
},
73+
paginate: response => {
74+
if (!Reflect.has(response.headers, 'link')) {
75+
return false;
76+
}
77+
78+
const items = (response.headers.link as string).split(',');
79+
80+
let next: string | undefined;
81+
for (const item of items) {
82+
const parsed = item.split(';');
83+
84+
if (parsed[1].includes('next')) {
85+
next = parsed[0].trimStart().trim();
86+
next = next.slice(1, -1);
87+
break;
88+
}
89+
}
90+
91+
if (next) {
92+
const options: GotOptions = {
93+
url: new URL(next)
94+
};
95+
96+
return options;
97+
}
98+
99+
return false;
100+
},
101+
filter: () => true,
102+
shouldContinue: () => true,
103+
countLimit: Infinity
104+
}
68105
},
69106
handlers: [defaultHandler],
70107
mutableDefaults: false

source/normalize-arguments.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export const preNormalizeArguments = (options: Options, defaults?: NormalizedOpt
170170
// Horrible `tough-cookie` check
171171
if (setCookie.length === 4 && getCookieString.length === 0) {
172172
if (!Reflect.has(setCookie, promisify.custom)) {
173-
// @ts-ignore TS is dumb.
173+
// @ts-ignore TS is dumb - it says `setCookie` is `never`.
174174
setCookie = promisify(setCookie.bind(options.cookieJar));
175175
getCookieString = promisify(getCookieString.bind(options.cookieJar));
176176
}
@@ -198,6 +198,34 @@ export const preNormalizeArguments = (options: Options, defaults?: NormalizedOpt
198198
options = merge({}, defaults, options);
199199
}
200200

201+
// `options._pagination`
202+
if (is.object(options._pagination)) {
203+
if (defaults && !(Reflect.has(options, 'pagination') && is.undefined(options._pagination))) {
204+
options._pagination = {
205+
...defaults.pagination,
206+
...options._pagination
207+
};
208+
}
209+
210+
const pagination = options._pagination!;
211+
212+
if (!is.function_(pagination.transform)) {
213+
throw new Error('`options._pagination.transform` must be implemented');
214+
}
215+
216+
if (!is.function_(pagination.shouldContinue)) {
217+
throw new Error('`options._pagination.shouldContinue` must be implemented');
218+
}
219+
220+
if (!is.function_(pagination.filter)) {
221+
throw new Error('`options._pagination.filter` must be implemented');
222+
}
223+
224+
if (!is.function_(pagination.paginate)) {
225+
throw new Error('`options._pagination.paginate` must be implemented');
226+
}
227+
}
228+
201229
// Other values
202230
options.decompress = Boolean(options.decompress);
203231
options.isStream = Boolean(options.isStream);

source/types.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export type DefaultOptions = Merge<
135135
'retry' |
136136
'timeout' |
137137
'context' |
138+
'_pagination' |
138139

139140
// Should not be present
140141
'agent' |
@@ -154,11 +155,22 @@ export type DefaultOptions = Merge<
154155
retry: DefaultRetryOptions;
155156
timeout: Delays;
156157
context: {[key: string]: any};
158+
_pagination?: PaginationOptions<unknown>['_pagination'];
157159
}
158160
>;
159161
/* eslint-enable @typescript-eslint/indent */
160162

161-
export interface GotOptions {
163+
export interface PaginationOptions<T> {
164+
_pagination?: {
165+
transform?: (response: Response) => Promise<T[]> | T[];
166+
filter?: (item: T, allItems: T[]) => boolean;
167+
paginate?: (response: Response) => Options | false;
168+
shouldContinue?: (item: T, allItems: T[]) => boolean;
169+
countLimit?: number;
170+
};
171+
}
172+
173+
export interface GotOptions extends PaginationOptions<unknown> {
162174
[requestSymbol]?: RequestFunction;
163175
url?: URL | string;
164176
body?: string | Buffer | ReadableStream;
@@ -206,6 +218,7 @@ export interface NormalizedOptions extends Options {
206218
cacheableRequest?: (options: string | URL | http.RequestOptions, callback?: (response: http.ServerResponse | ResponseLike) => void) => CacheableRequest.Emitter;
207219
cookieJar?: PromiseCookieJar;
208220
maxRedirects: number;
221+
pagination?: Required<PaginationOptions<unknown>['_pagination']>;
209222
[requestSymbol]: RequestFunction;
210223

211224
// Other values

0 commit comments

Comments
 (0)