Skip to content

Commit

Permalink
Pass all Ky options to hooks (#188)
Browse files Browse the repository at this point in the history
  • Loading branch information
sholladay authored and szmarczak committed Nov 7, 2019
1 parent 3b87b93 commit a5c249e
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 99 deletions.
6 changes: 3 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,11 @@ export interface Options extends RequestInit {
json?: unknown;

/**
Search parameters to include in the request URL.
Search parameters to include in the request URL. Setting this will override all existing search parameters in the input URL.
Setting this will override all existing search parameters in the input URL.
Accepts any value supported by [`URLSearchParams()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams).
*/
searchParams?: string | {[key: string]: string | number} | URLSearchParams;
searchParams?: string | {[key: string]: string | number | boolean} | Array<Array<string | number | boolean>> | URLSearchParams;

/**
When specified, `prefixUrl` will be prepended to `input`. The prefix can be any valid URL, either relative or absolute. A trailing slash `/` is optional, one will be added automatically, if needed, when joining `prefixUrl` and `input`. The `input` argument cannot start with a `/` when using this option.
Expand Down
113 changes: 42 additions & 71 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const globals = {};
};

const globalProperties = [
'document',
'Headers',
'Request',
'Response',
Expand Down Expand Up @@ -107,30 +106,30 @@ const responseTypes = {
blob: '*/*'
};

const retryMethods = new Set([
const retryMethods = [
'get',
'put',
'head',
'delete',
'options',
'trace'
]);
];

const retryStatusCodes = new Set([
const retryStatusCodes = [
408,
413,
429,
500,
502,
503,
504
]);
];

const retryAfterStatusCodes = new Set([
const retryAfterStatusCodes = [
413,
429,
503
]);
];

class HTTPError extends Error {
constructor(response) {
Expand Down Expand Up @@ -179,7 +178,7 @@ const defaultRetryOptions = {
afterStatusCodes: retryAfterStatusCodes
};

const normalizeRetryOptions = retry => {
const normalizeRetryOptions = (retry = {}) => {
if (typeof retry === 'number') {
return {
...defaultRetryOptions,
Expand All @@ -198,54 +197,34 @@ const normalizeRetryOptions = retry => {
return {
...defaultRetryOptions,
...retry,
methods: retry.methods ? new Set(retry.methods) : defaultRetryOptions.methods,
statusCodes: retry.statusCodes ? new Set(retry.statusCodes) : defaultRetryOptions.statusCodes,
afterStatusCodes: retryAfterStatusCodes
};
};

const assertSafeTimeout = timeout => {
if (timeout > 2147483647) { // The maximum value of a 32bit int (see issue #117)
throw new RangeError('The `timeout` option cannot be greater than 2147483647');
}
};
// The maximum value of a 32bit int (see issue #117)
const maxSafeTimeout = 2147483647;

class Ky {
constructor(input, {
hooks,
json,
onDownloadProgress,
prefixUrl,
retry = {},
searchParams,
throwHttpErrors = true,
timeout = 10000,
...fetchOptions
}) {
constructor(input, options = {}) {
this._retryCount = 0;
this._input = input;
this._options = {
// TODO: credentials can be removed when the spec change is implemented in all browsers. Context: https://www.chromestatus.com/feature/4539473312350208
credentials: this._input.credentials || 'same-origin',
...options,
hooks: deepMerge({
beforeRequest: [],
beforeRetry: [],
afterResponse: []
}, hooks),
json,
onDownloadProgress,
prefixUrl: String(prefixUrl || ''),
retry: normalizeRetryOptions(retry),
searchParams,
throwHttpErrors,
timeout
};
this._fetchOptions = {
// TODO: credentials can be removed when the spec change is implemented in all browsers. Context: https://www.chromestatus.com/feature/4539473312350208
credentials: this._input.credentials || 'same-origin',
...fetchOptions,
method: normalizeRequestMethod(fetchOptions.method || this._input.method)
}, options.hooks),
method: normalizeRequestMethod(options.method || this._input.method),
prefixUrl: String(options.prefixUrl || ''),
retry: normalizeRetryOptions(options.retry),
throwHttpErrors: options.throwHttpErrors !== false,
timeout: typeof options.timeout === 'undefined' ? 10000 : options.timeout
};

if (typeof input !== 'string' && !(input instanceof URL || input instanceof globals.Request)) {
if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globals.Request)) {
throw new TypeError('`input` must be a string, URL, or Request');
}

Expand All @@ -263,53 +242,45 @@ class Ky {

if (supportsAbortController) {
this.abortController = new globals.AbortController();
if (this._fetchOptions.signal) {
this._fetchOptions.signal.addEventListener('abort', () => {
if (this._options.signal) {
this._options.signal.addEventListener('abort', () => {
this.abortController.abort();
});
this._fetchOptions.signal = this.abortController.signal;
this._options.signal = this.abortController.signal;
}
}

this.request = new globals.Request(this._input, this._fetchOptions);

if (searchParams) {
const url = new URL(this._input.url || this._input, globals.document && globals.document.baseURI);
if (typeof searchParams === 'string' || (URLSearchParams && searchParams instanceof URLSearchParams)) {
url.search = searchParams;
} else if (Object.values(searchParams).every(param => typeof param === 'number' || typeof param === 'string')) {
url.search = new URLSearchParams(searchParams).toString();
} else {
throw new Error('The `searchParams` option must be either a string, `URLSearchParams` instance or an object with string and number values');
}
this.request = new globals.Request(this._input, this._options);

if (this._options.searchParams) {
const url = new URL(this.request.url);
url.search = new URLSearchParams(this._options.searchParams);
this.request = new globals.Request(url, this.request);
}

if (((supportsFormData && this._fetchOptions.body instanceof globals.FormData) || this._fetchOptions.body instanceof URLSearchParams) && this.request.headers.has('content-type')) {
throw new Error(`The \`content-type\` header cannot be used with a ${this._fetchOptions.body.constructor.name} body. It will be set automatically.`);
if (((supportsFormData && this._options.body instanceof globals.FormData) || this._options.body instanceof URLSearchParams) && this.request.headers.has('content-type')) {
throw new Error(`The \`content-type\` header cannot be used with a ${this._options.body.constructor.name} body. It will be set automatically.`);
}

if (this._options.json) {
if (this._fetchOptions.body) {
throw new Error('The `json` option cannot be used with the `body` option');
}

this._fetchOptions.body = JSON.stringify(json);
this._options.body = JSON.stringify(this._options.json);
this.request.headers.set('content-type', 'application/json');
this.request = new globals.Request(this.request, {body: this._fetchOptions.body});
this.request = new globals.Request(this.request, {body: this._options.body});
}

const fn = async () => {
assertSafeTimeout(timeout);
if (this._options.timeout > maxSafeTimeout) {
throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
}

await delay(1);
let response = await this._fetch();

for (const hook of this._options.hooks.afterResponse) {
// eslint-disable-next-line no-await-in-loop
const modifiedResponse = await hook(
this.request,
this._fetchOptions,
this._options,
response.clone()
);

Expand Down Expand Up @@ -339,7 +310,7 @@ class Ky {
return response;
};

const isRetriableMethod = this._options.retry.methods.has(this.request.method.toLowerCase());
const isRetriableMethod = this._options.retry.methods.includes(this.request.method.toLowerCase());
const result = isRetriableMethod ? this._retry(fn) : fn();

for (const [type, mimeType] of Object.entries(responseTypes)) {
Expand All @@ -357,12 +328,12 @@ class Ky {

if (this._retryCount < this._options.retry.limit && !(error instanceof TimeoutError)) {
if (error instanceof HTTPError) {
if (!this._options.retry.statusCodes.has(error.response.status)) {
if (!this._options.retry.statusCodes.includes(error.response.status)) {
return 0;
}

const retryAfter = error.response.headers.get('Retry-After');
if (retryAfter && this._options.retry.afterStatusCodes.has(error.response.status)) {
if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) {
let after = Number(retryAfter);
if (Number.isNaN(after)) {
after = Date.parse(retryAfter) - Date.now();
Expand Down Expand Up @@ -393,15 +364,15 @@ class Ky {
try {
return await fn();
} catch (error) {
const ms = this._calculateRetryDelay(error);
const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout);
if (ms !== 0 && this._retryCount > 0) {
await delay(ms);

for (const hook of this._options.hooks.beforeRetry) {
// eslint-disable-next-line no-await-in-loop
await hook(
this.request,
this._fetchOptions,
this._options,
error,
this._retryCount,
);
Expand All @@ -419,7 +390,7 @@ class Ky {
async _fetch() {
for (const hook of this._options.hooks.beforeRequest) {
// eslint-disable-next-line no-await-in-loop
const result = await hook(this.request, this._fetchOptions);
const result = await hook(this.request, this._options);

if (result instanceof Request) {
this.request = result;
Expand Down
14 changes: 14 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ customKy(input, options);
ky(url, {searchParams: 'foo=bar'});
ky(url, {searchParams: {foo: 'bar'}});
ky(url, {searchParams: {foo: 1}});
ky(url, {searchParams: {foo: true}});
ky(url, {searchParams: [['foo', 'bar']]});
ky(url, {searchParams: [['foo', 1]]});
ky(url, {searchParams: [['foo', true]]});
ky(url, {searchParams: new URLSearchParams({foo: 'bar'})});

// `json` option
Expand All @@ -125,3 +129,13 @@ ky(url, {
expectType<Uint8Array>(chunk);
}
});

// `retry` option
ky(url, {retry: 100});
ky(url, {
retry: {
methods: [],
statusCodes: [],
afterStatusCodes: []
}
});
4 changes: 3 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,13 @@ Shortcut for sending JSON. Use this instead of the `body` option. Accepts a plai

##### searchParams

Type: `string | object<string, string | number> | URLSearchParams`<br>
Type: `string | object<string, string | number | boolean> | Array<Array<string | number | boolean>> | URLSearchParams`<br>
Default: `''`

Search parameters to include in the request URL. Setting this will override all existing search parameters in the input URL.

Accepts any value supported by [`URLSearchParams()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams).

##### prefixUrl

Type: `string | URL`
Expand Down
6 changes: 3 additions & 3 deletions test/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,10 @@ test('`afterResponse` hook is called with request, normalized options, and respo
// Retry request with valid token
return ky(request, {
...options,
body: JSON.stringify({
...JSON.parse(options.body),
json: {
...options.json,
token: 'valid:token'
})
}
});
}
}
Expand Down
50 changes: 31 additions & 19 deletions test/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,27 @@ test('cannot use `json` option with GET or HEAD method', t => {
}, 'Request with GET/HEAD method cannot have body');
});

test('cannot use `json` option along with the `body` option', t => {
t.throws(() => {
ky.post('https://example.com', {json: {foo: 'bar'}, body: 'foobar'});
}, 'The `json` option cannot be used with the `body` option');
test('`json` option overrides the `body` option', async t => {
t.plan(2);

const server = await createTestServer();
server.post('/', async (request, response) => {
t.is(request.headers['content-type'], 'application/json');
response.json(JSON.parse(await pBody(request)));
});

const json = {
foo: 'bar'
};

const responseJson = await ky.post(server.url, {
body: 'hello',
json
}).json();

t.deepEqual(json, responseJson);

await server.close();
});

test('custom headers', async t => {
Expand Down Expand Up @@ -244,24 +261,19 @@ test('searchParams option', async t => {
response.end(request.url.slice(1));
});

const stringParams = '?pass=true';
const objectParams = {pass: 'true'};
const searchParams = new URLSearchParams(stringParams);
const arrayParams = [['cats', 'meow'], ['dogs', true], ['opossums', false]];
const objectParams = {
cats: 'meow',
dogs: true,
opossums: false
};
const searchParams = new URLSearchParams(arrayParams);
const stringParams = '?cats=meow&dogs=true&opossums=false';

t.is(await ky(server.url, {searchParams: stringParams}).text(), stringParams);
t.is(await ky(server.url, {searchParams: arrayParams}).text(), stringParams);
t.is(await ky(server.url, {searchParams: objectParams}).text(), stringParams);
t.is(await ky(server.url, {searchParams}).text(), stringParams);

t.throws(() => {
ky(server.url, {
searchParams: {
pass: [
'true',
'false'
]
}
});
}, /`searchParams` option must be/);
t.is(await ky(server.url, {searchParams: stringParams}).text(), stringParams);

await server.close();
});
Expand Down
4 changes: 2 additions & 2 deletions test/retry.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ test('throws when retry.methods is not an array', async t => {
t.throws(() => {
ky(server.url, {
retry: {
methods: new Set(['get'])
methods: 'get'
}
});
});
Expand All @@ -376,7 +376,7 @@ test('throws when retry.statusCodes is not an array', async t => {
t.throws(() => {
ky(server.url, {
retry: {
statusCodes: new Set([403])
statusCodes: 403
}
});
});
Expand Down

0 comments on commit a5c249e

Please sign in to comment.