/**
* Http Error
*/
export class HttpError {
/**
* @contructor
* @param req request
* @param resOrErr response or Error
*/
constructor(req, resOrErr) {
this.req = req;
if (typeof resOrErr === "string") {
this.res = null;
this.isTimeout = resOrErr === "timeout";
this.isAborted = resOrErr === "abort";
this.isNetworkError = resOrErr === "error";
} else {
this.res = resOrErr;
const code = this.res.status;
this.isBadRequest = code === 400;
this.isUnauthorized = code === 401;
this.isForbidden = code === 403;
this.isNotFound = code === 404;
this.isConflict = code === 409;
this.isServerError = code >= 500;
}
}
}
/**
* HTTP Client
*
* @class HttpClient
*/
export class HttpClient {
/**
* Constructor.
* @param {object} [opts] - options
* @param {string} [opts.baseURL] - base URL (default empty).
* @param {object} [opts.headers] - custom headers
* @param {string} [opts.bodyType] - post/put type `form` | `json`
*/
constructor(opts = {}) {
this.baseURL = opts.baseURL || "";
this.headers = opts.headers || {};
const bodyType = opts.bodyType || "";
if (bodyType.includes("form")) {
this.contentType = "application/x-www-form-urlencoded";
} else {
this.contentType = "application/json;charset=UTF-8";
}
}
/**
* hook async function called before the request.
*
* @param {String} path - request path
* @param {object} [ctx] - context
* @param {object} [ctx.query] - request query data
* @param {object} [ctx.body] - request body
* @param {object} [ctx.headers] - header name and value object
*/
async beforeRequest(path, ctx) {}
/**
* hook async function called before throw an error.
*
* @param {HttpError} err - request path
* @return {Promise<boolean>} - if return false, stop throwing the error.
*/
async beforeError(err) {
return true;
}
/**
* do GET request.
*
* @param {String} path - request path
* @param {object} [ctx] - context
* @param {object} [ctx.query] - request query data
* @param {object} [ctx.body] - request body
* @param {object} [ctx.headers] - header name and value object
* @returns {Promise<{status:number,headers:object,body:*}>} Promise resolving to an object with response details
*/
get(path, ctx = {}) {
const { query, body, headers } = ctx;
return this.exec("GET", path, query, body, headers);
}
/**
* do POST request.
*
* @param {String} path - request path
* @param {object} [ctx] - context
* @param {object} [ctx.query] - request query data
* @param {object} [ctx.body] - request body
* @param {object} [ctx.headers] - header name and value object
* @returns {Promise<{status:number,headers:object,body:*}>} Promise resolving to an object with response details
*/
post(path, ctx = {}) {
const { query, body, headers } = ctx;
return this.exec("POST", path, query, body, headers);
}
/**
* do PUT request.
*
* @param {String} path - request path
* @param {object} [ctx] - context
* @param {object} [ctx.query] - request query data
* @param {object} [ctx.body] - request body
* @param {object} [ctx.headers] - header name and value object
* @returns {Promise<{status:number,headers:object,body:*}>} Promise resolving to an object with response details
*/
put(path, ctx = {}) {
const { query, body, headers } = ctx;
return this.exec("PUT", path, query, body, headers);
}
/**
* do PATCH request.
*
* @param {String} path - request path
* @param {object} [ctx] - context
* @param {object} [ctx.query] - request query data
* @param {object} [ctx.body] - request body
* @param {object} [ctx.headers] - header name and value object
* @returns {Promise<{status:number,headers:object,body:*}>} Promise resolving to an object with response details
*/
patch(path, ctx = {}) {
const { query, body, headers } = ctx;
return this.exec("PATCH", path, query, body, headers);
}
/**
* do DELETE request.
*
* @param {String} path - request path
* @param {object} [ctx] - context
* @param {object} [ctx.query] - request query data
* @param {object} [ctx.body] - request body
* @param {object} [ctx.headers] - header name and value object
* @returns {Promise<{status:number,headers:object,body:*}>} Promise resolving to an object with response details
*/
delete(path, ctx = {}) {
const { query, body, headers } = ctx;
return this.exec("DELETE", path, query, body, headers);
}
/**
* execute http request.
*
* @param {string} method - method
* @param {string} path - request path
* @param {object} query - request query data
* @param {string|object} body - request body
* @param {object} headers - header name and value object
* @returns {Promise<{status:number,headers:object,body:*}>} Promise resolving to an object with response details
*/
async exec(method, path, query, body, headers = {}) {
headers = { ...this.headers, ...headers };
if (!("Content-Type" in headers)) {
headers["Content-Type"] = this.contentType;
}
await this.beforeRequest(path, { query, body, headers });
const req = { method, path, headers };
let httpErr;
try {
const res = await this._request(method, path, { query, body, headers });
const { status } = res;
if (status < 400) {
return res;
}
httpErr = new HttpError(req, res);
} catch (errType) {
httpErr = new HttpError(req, errType);
}
if ((await this.beforeError(httpErr)) !== false) {
throw httpErr;
}
}
_request(method, path, { query, body, headers }) {
let reqBody;
if (body) {
const cttType = headers["Content-Type"];
if (cttType.match(/\/form-data/)) {
reqBody = new FormData();
for (const field in body) {
reqBody.append(field, body[field]);
}
delete headers["Content-Type"]; // FormData set it with boundary
} else if (cttType.match(/\/json/)) {
reqBody = JSON.stringify(body);
} else {
reqBody = this._formatParams(body);
}
} else {
reqBody = undefined;
}
let url = path;
if (this.baseURL && !url.match(/^[a-z]{2,5}:\/\//)) {
url = this.baseURL + url;
}
if (query) url += "?" + this._formatParams(query);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url, true);
for (const name in headers) {
xhr.setRequestHeader(name, headers[name]);
}
xhr.onload = (evt) => {
let resBody,
resCttType = xhr.getResponseHeader("Content-Type");
if (resCttType === null) {
resBody = null;
} else if (resCttType.match(/\/json/)) {
resBody = JSON.parse(xhr.response);
} else if (resCttType.match(/\/form/)) {
resBody = this._fromFormData(xhr.response);
} else {
resBody = xhr.response;
}
resolve({
status: xhr.status,
headers: xhr.getAllResponseHeaders(),
body: resBody,
});
};
xhr.onabort = (err) => {
reject("abort");
};
xhr.onerror = (err) => {
reject("error");
};
xhr.ontimeout = (err) => {
reject("timeout");
};
xhr.send(reqBody);
});
}
_formatParams(params) {
return new URLSearchParams(params).toString();
}
_fromFormData(body) {
const data = {};
body.split("&").forEach((item) => {
const [key, val] = item.split("=");
data[decodeURIComponent(key)] = decodeURIComponent(val);
});
return data;
}
}