@digitalbazaar/http-client
Version:
An opinionated, isomorphic HTTP client.
155 lines (134 loc) • 4.45 kB
JavaScript
/*!
* Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved.
*/
import {convertAgent} from './agentCompatibility.js';
import {deferred} from './deferred.js';
export const kyOriginalPromise = deferred(() => import('ky')
.then(({default: ky}) => ky));
export const DEFAULT_HEADERS = {
Accept: 'application/ld+json, application/json'
};
// methods to proxy from ky
const PROXY_METHODS = new Set([
'get', 'post', 'put', 'push', 'patch', 'head', 'delete'
]);
/**
* Returns a custom httpClient instance. Used to specify default headers and
* other default overrides.
*
* @param {object} [options={}] - Options hashmap.
* @param {object} [options.parent] - The ky promise to inherit from.
* @param {object} [options.headers={}] - Default header overrides.
* @param {object} [options.params] - Other default overrides.
*
* @returns {Function} Custom httpClient instance.
*/
export function createInstance({
parent = kyOriginalPromise, headers = {}, ...params
} = {}) {
// convert legacy agent options
params = convertAgent(params);
// create new ky instance that will asynchronously resolve
const kyPromise = deferred(() => parent.then(kyBase => {
let ky;
if(parent === kyOriginalPromise) {
// ensure default headers, allow overrides
ky = kyBase.create({
headers: {...DEFAULT_HEADERS, ...headers},
...params
});
} else {
// extend parent
ky = kyBase.extend({headers, ...params});
}
return ky;
}));
return _createHttpClient(kyPromise);
}
function _createHttpClient(kyPromise) {
async function httpClient(...args) {
const ky = await kyPromise;
const method = ((args[1] && args[1].method) || 'get').toLowerCase();
if(PROXY_METHODS.has(method)) {
return httpClient[method].apply(ky[method], args);
}
// convert legacy agent options
args[1] = convertAgent(args[1]);
return ky.apply(ky, args);
}
for(const method of PROXY_METHODS) {
httpClient[method] = async function(...args) {
const ky = await kyPromise;
return _handleResponse(ky[method], ky, args);
};
}
httpClient.create = function({headers = {}, ...params}) {
return createInstance({headers, ...params});
};
httpClient.extend = function({headers = {}, ...params}) {
return createInstance({parent: kyPromise, headers, ...params});
};
// default async `stop` signal getter
Object.defineProperty(httpClient, 'stop', {
async get() {
const ky = await kyPromise;
return ky.stop;
}
});
return httpClient;
}
async function _handleResponse(target, thisArg, args) {
// convert legacy agent options
args[1] = convertAgent(args[1]);
let response;
const [url] = args;
try {
response = await target.apply(thisArg, args);
} catch(error) {
return _handleError({error, url});
}
const {parseBody = true} = args[1] || {};
// always set 'data', default to undefined
let data;
if(parseBody) {
// a 204 will not include a content-type header
const contentType = response.headers.get('content-type');
if(contentType && contentType.includes('json')) {
data = await response.json();
}
}
Object.defineProperty(response, 'data', {value: data});
return response;
}
/**
* @param {object} options - Options hashmap.
* @param {Error} options.error - Error thrown during http operation.
* @param {string} options.url - Target URL of the request.
*
* @returns {Promise} Rejects with a thrown error.
*/
async function _handleError({error, url}) {
error.requestUrl = url;
// handle network errors and system errors that do not have a response
if(!error.response) {
if(error.message === 'Failed to fetch') {
error.message = `Failed to fetch "${url}". Possible CORS error.`;
}
// ky's TimeoutError class
if(error.name === 'TimeoutError') {
error.message = `Request to "${url}" timed out.`;
}
throw error;
}
// always move status up to the root of error
error.status = error.response.status;
const contentType = error.response.headers.get('content-type');
if(contentType && contentType.includes('json')) {
const errorBody = await error.response.json();
// the HTTPError received from ky has a generic message based on status
// use that if the JSON body does not include a message
error.message = errorBody.message || error.message;
error.data = errorBody;
}
throw error;
}