@ima/core
Version:
IMA.js framework for isomorphic javascript application
439 lines (438 loc) • 17.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
Object.defineProperty(exports, "HttpProxy", {
enumerable: true,
get: function() {
return HttpProxy;
}
});
const _HttpStatusCode = require("./HttpStatusCode");
const _GenericError = require("../error/GenericError");
class HttpProxy {
_transformer;
_window;
_defaultHeaders;
/**
* Initializes the HTTP proxy.
*
* @param transformer A transformer of URLs to which
* requests are made.
* @param window Helper for manipulating the global object `window`
* regardless of the client/server-side environment.
*/ constructor(transformer, window){
/**
* A transformer of URLs to which requests are made.
*/ this._transformer = transformer;
/**
* Helper for manipulating the global object `window` regardless of the
* client/server-side environment.
*/ this._window = window;
/**
* The default HTTP headers to include with the HTTP requests, unless
* overridden.
*/ this._defaultHeaders = new Map();
}
/**
* Executes a HTTP request to the specified URL using the specified HTTP
* method, carrying the provided data.
*
* @param method The HTTP method to use.
* @param url The URL to which the request should be made.
* @param data The data to
* be send to the server. The data will be included as query
* parameters if the request method is `GET` or `HEAD`, and as
* a request body for any other request method.
* @param options Optional request options.
* @return A promise that resolves to the server
* response.
*/ request(method, url, data, options) {
const requestParams = this._composeRequestParams(method, url, data, options);
// Track request timeout status
let requestTimeoutId = null;
let isTimeoutAbortDefined = false;
if (options.timeout && !options.abortController && !options.fetchOptions?.signal) {
isTimeoutAbortDefined = true;
options.abortController = new AbortController();
}
return new Promise((resolve, reject)=>{
if (options.timeout) {
requestTimeoutId = setTimeout(()=>{
options.abortController?.abort();
// Reset timeout abort controller for another attempt
if (isTimeoutAbortDefined && options.repeatRequest > 0) {
options.abortController = new AbortController();
options.fetchOptions.signal = options.abortController?.signal;
}
return reject(new _GenericError.GenericError('The HTTP request timed out', {
status: _HttpStatusCode.HttpStatusCode.TIMEOUT
}));
}, options.timeout);
}
fetch(this._composeRequestUrl(url, !this._shouldRequestHaveBody(method, data) ? data : {}), this._composeRequestInit(method, data, options)).then((response)=>{
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const contentType = response.headers.get('content-type');
/**
* We usually want to parse the response body as JSON, when the
* response status is not OK, and the content type is JSON.
*
* This overrides responseType options and allows to parse the response
* body as JSON even when the response status is not OK.
*/ if (!response.ok && contentType?.includes('application/json')) {
return response.json().then((body)=>[
response,
body
]);
}
// Parse content by the new responseType option
if (options?.responseType) {
return response[options.responseType]().then((body)=>[
response,
body
]);
}
if (response.status === _HttpStatusCode.HttpStatusCode.NO_CONTENT) {
return Promise.resolve([
response,
null
]);
} else if (contentType && contentType.includes('application/json')) {
return response.json().then((body)=>[
response,
body
]);
} else {
return response.text().then((body)=>[
response,
body
]);
}
}).then(([response, responseBody])=>this._processResponse(requestParams, response, responseBody)).then(resolve, reject);
}).catch((fetchError)=>{
throw this._processError(fetchError, requestParams);
});
}
/**
* Sets the specified default HTTP header. The header will be sent with all
* subsequent HTTP requests unless reconfigured using this method,
* overridden by request options, or cleared by
* {@link HttpProxy#clearDefaultHeaders} method.
*
* @param header A header name.
* @param value A header value.
* @returns this
*/ setDefaultHeader(header, value) {
this._defaultHeaders.set(header, value);
return this;
}
/**
* Clears all defaults headers sent with all requests.
*
* @returns this
*/ clearDefaultHeaders() {
this._defaultHeaders.clear();
return this;
}
/**
* Gets an object that describes a failed HTTP request, providing
* information about both the failure reported by the server and how the
* request has been sent to the server.
*
* @param method The HTTP method used to make the request.
* @param url The URL to which the request has been made.
* @param data The data sent
* with the request.
* @param options Optional request options.
* @param status The HTTP response status code send by the server.
* @param body The body of HTTP error response (detailed
* information).
* @param cause The low-level cause error.
* @return An object containing both the details of
* the error and the request that lead to it.
*/ getErrorParams(method, url, data, options, status, body, cause) {
let errorName = '';
const params = this._composeRequestParams(method, url, data, options);
if (typeof body === 'undefined') {
body = {};
}
switch(status){
case _HttpStatusCode.HttpStatusCode.TIMEOUT:
errorName = 'Timeout';
break;
case _HttpStatusCode.HttpStatusCode.BAD_REQUEST:
errorName = 'Bad Request';
break;
case _HttpStatusCode.HttpStatusCode.UNAUTHORIZED:
errorName = 'Unauthorized';
break;
case _HttpStatusCode.HttpStatusCode.FORBIDDEN:
errorName = 'Forbidden';
break;
case _HttpStatusCode.HttpStatusCode.NOT_FOUND:
errorName = 'Not Found';
break;
case _HttpStatusCode.HttpStatusCode.SERVER_ERROR:
errorName = 'Internal Server Error';
break;
default:
errorName = 'Unknown';
break;
}
return {
errorName,
status,
body,
cause,
...params
};
}
/**
* Returns `true` if cookies have to be processed manually by setting
* `Cookie` HTTP header on requests and parsing the `Set-Cookie` HTTP
* response header.
*
* The result of this method depends on the current application
* environment, the client-side usually handles cookie processing
* automatically, leading this method returning `false`.
*
* At the client-side, the method tests whether the client has cookies
* enabled (which results in cookies being automatically processed by the
* browser), and returns `true` or `false` accordingly.
*
* `true` if cookies are not processed automatically by
* the environment and have to be handled manually by parsing
* response headers and setting request headers, otherwise `false`.
*/ haveToSetCookiesManually() {
return !this._window.isClient();
}
/**
* Processes the response received from the server.
*
* @param requestParams The original request's
* parameters.
* @param response The Fetch API's `Response` object representing
* the server's response.
* @param responseBody The server's response body.
* @return The server's response along with all related
* metadata.
*/ _processResponse(requestParams, response, responseBody) {
if (response.ok) {
return {
status: response.status,
body: responseBody,
params: requestParams,
headers: this._headersToPlainObject(response.headers),
headersRaw: response.headers,
cached: false
};
}
throw new _GenericError.GenericError('The request failed', {
status: response.status,
body: responseBody,
response: response
});
}
/**
* Converts the provided Fetch API's `Headers` object to a plain object.
*
* @param headers The headers to convert.
* @return Converted headers.
*/ _headersToPlainObject(headers) {
const plainHeaders = {};
for (const [key, value] of headers){
plainHeaders[key] = value;
}
return plainHeaders;
}
/**
* Processes the provided Fetch API or internal error and creates an error
* to expose to the calling API.
*
* @param fetchError The internal error to process.
* @param requestParams An object representing the
* complete request parameters used to create and send the HTTP
* request.
* @return The error to provide to the calling API.
*/ _processError(fetchError, requestParams) {
const errorParams = fetchError instanceof _GenericError.GenericError ? fetchError.getParams() : {};
return this._createError(fetchError, requestParams, errorParams.status || _HttpStatusCode.HttpStatusCode.SERVER_ERROR, errorParams.body || null);
}
/**
* Creates an error that represents a failed HTTP request.
*
* @param cause The error's message.
* @param requestParams An object representing the
* complete request parameters used to create and send the HTTP
* request.
* @param status Server's response HTTP status code.
* @param responseBody The body of the server's response, if any.
* @return The error representing a failed HTTP request.
*/ _createError(cause, requestParams, status, responseBody = null) {
return new _GenericError.GenericError(cause.message, this.getErrorParams(requestParams.method, requestParams.url, requestParams.data, requestParams.options, status, responseBody, cause));
}
/**
* Composes an object representing the HTTP request parameters from the
* provided arguments.
*
* @param method The HTTP method to use.
* @param url The URL to which the request should be sent.
* @param data The data to
* send with the request.
* @param options Optional request options.
* @return An object representing the complete request parameters used to create and
* send the HTTP request.
*/ _composeRequestParams(method, url, data, options) {
return {
method,
url,
transformedUrl: this._transformer.transform(url),
data,
options
};
}
/**
* Composes an init object, which can be used as a second argument of
* `window.fetch` method.
*
* @param method The HTTP method to use.
* @param data The data to
* be send with a request.
* @param options Options provided by the HTTP
* agent.
* @return {ImaRequestInit} An `ImaRequestInit` object (extended from `RequestInit` of the Fetch API).
*/ _composeRequestInit(method, data, options) {
const requestInit = {
method: method.toUpperCase(),
redirect: 'follow',
headers: options.fetchOptions?.headers || {}
};
const contentType = this._getContentType(method, data, requestInit.headers);
if (contentType) {
requestInit.headers['Content-Type'] = contentType;
}
for (const [headerName, headerValue] of this._defaultHeaders){
requestInit.headers[headerName] = headerValue;
}
if (this._shouldRequestHaveBody(method, data)) {
requestInit.body = this._transformRequestBody(data, requestInit.headers);
}
// Re-assign signal from abort controller to fetch options
if (!options.fetchOptions?.signal && options.abortController?.signal) {
options.fetchOptions = {
...options.fetchOptions,
signal: options.abortController.signal
};
}
Object.assign(requestInit, options.fetchOptions || {});
return requestInit;
}
/**
* Gets a `Content-Type` header value for defined method, data and options.
*
* @param method The HTTP method to use.
* @param data The data to
* be send with a request.
* @param options Options provided by the HTTP
* agent.
* @return A `Content-Type` header value, null for requests
* with no body.
*/ _getContentType(method, data, headers) {
if (headers['Content-Type'] && typeof headers['Content-Type'] === 'string') {
return headers['Content-Type'];
}
if (this._shouldRequestHaveBody(method, data)) {
return 'application/json';
}
return null;
}
/**
* Transforms the provided URL using the current URL transformer and adds
* the provided data to the URL's query string.
*
* @param url The URL to prepare for use with the fetch API.
* @param data The data to be attached to the query string.
* @return The transformed URL with the provided data attached to
* its query string.
*/ _composeRequestUrl(url, data) {
const transformedUrl = this._transformer.transform(url);
const queryString = this._convertObjectToQueryString(data || {});
const delimiter = queryString ? transformedUrl.includes('?') ? '&' : '?' : '';
return `${transformedUrl}${delimiter}${queryString}`;
}
/**
* Checks if a request should have a body (`GET` and `HEAD` requests don't
* have a body).
*
* @param method The HTTP method.
* @param data The data to
* be send with a request.
* @return `true` if a request has a body, otherwise `false`.
*/ _shouldRequestHaveBody(method, data) {
return !!(method && data && ![
'get',
'head'
].includes(method.toLowerCase()));
}
/**
* Formats request body according to request headers.
*
* @param data The data to
* be send with a request.
* @param headers Headers object from options provided by the HTTP
* agent.
* @private
*/ _transformRequestBody(data, headers) {
switch(headers['Content-Type']){
case 'application/json':
return JSON.stringify(data);
case 'application/x-www-form-urlencoded':
return this._convertObjectToQueryString(data);
case 'multipart/form-data':
return this._convertObjectToFormData(data);
default:
return data;
}
}
/**
* Returns query string representation of the data parameter.
* (Returned string does not contain ? at the beginning)
*
* @param object The object to be converted
* @returns Query string representation of the given object
* @private
*/ _convertObjectToQueryString(object) {
if (!object) {
return undefined;
}
return Object.keys(object).map((key)=>[
key,
object[key]
].map((value)=>{
return encodeURIComponent(value);
}).join('=')).join('&');
}
/**
* Converts given data to FormData object.
* If FormData object is not supported by the browser the original object is returned.
*
* @param object The object to be converted
* @returns
* @private
*/ _convertObjectToFormData(object) {
if (!object) {
return undefined;
}
const window = this._window.getWindow();
if (!window || !FormData) {
return object;
}
const formDataObject = new FormData();
Object.keys(object).forEach((key)=>formDataObject.append(key, object[key]));
return formDataObject;
}
}
//# sourceMappingURL=HttpProxy.js.map