@ima/core
Version:
IMA.js framework for isomorphic javascript application
370 lines (369 loc) • 14.6 kB
JavaScript
import { HttpAgent } from './HttpAgent';
import { GenericError } from '../error/GenericError';
/**
* Implementation of the {@link HttpAgent} interface with internal caching
* of completed and ongoing HTTP requests and cookie storage.
*/ export class HttpAgentImpl extends HttpAgent {
_proxy;
_cache;
_cookie;
_cacheOptions;
_defaultRequestOptions;
_Helper;
_internalCacheOfPromises = new Map();
/**
* Initializes the HTTP handler.
*
* @param proxy The low-level HTTP proxy for sending the HTTP
* requests.
* @param cache Cache to use for caching ongoing and completed
* requests.
* @param cookie The cookie storage to use internally.
* @param Helper The IMA.js helper methods.
* @param config Configuration of the HTTP handler for
* the current application environment, specifying the various
* default request option values and cache option values.
* @example
* http
* .get('url', { data: data }, {
* ttl: 2000,
* repeatRequest: 1,
* withCredentials: true,
* timeout: 2000,
* accept: 'application/json',
* language: 'en'
* })
* .then((response) => {
* //resolve
* }
* .catch((error) => {
* //catch
* });
* @example
* http
* .setDefaultHeader('Accept-Language', 'en')
* .clearDefaultHeaders();
*/ constructor(proxy, cache, cookie, config, Helper){
super();
/**
* HTTP proxy, used to execute the HTTP requests.
*/ this._proxy = proxy;
/**
* Internal request cache, used to cache completed request results.
*/ this._cache = cache;
/**
* Cookie storage, used to keep track of cookies received from the
* server and send them with the subsequent requests to the server.
*/ this._cookie = cookie;
this._cacheOptions = config.cacheOptions;
this._defaultRequestOptions = config.defaultRequestOptions;
/**
* Tha IMA.js helper methods.
*/ this._Helper = Helper;
}
/**
* @inheritDoc
*/ get(url, data, options) {
return this._requestWithCheckCache('get', url, data, options);
}
/**
* @inheritDoc
*/ post(url, data, options) {
return this._requestWithCheckCache('post', url, data, Object.assign({
cache: false
}, options));
}
/**
* @inheritDoc
*/ put(url, data, options) {
return this._requestWithCheckCache('put', url, data, Object.assign({
cache: false
}, options));
}
/**
* @inheritDoc
*/ patch(url, data, options) {
return this._requestWithCheckCache('patch', url, data, Object.assign({
cache: false
}, options));
}
/**
* @inheritDoc
*/ delete(url, data, options) {
return this._requestWithCheckCache('delete', url, data, Object.assign({
cache: false
}, options));
}
/**
* @inheritDoc
*/ getCacheKey(method, url, data) {
return this._cacheOptions.prefix + this._getCacheKeySuffix(method, url, data);
}
/**
* @inheritDoc
*/ invalidateCache(method, url, data) {
const cacheKey = this.getCacheKey(method, url, data);
this._cache.delete(cacheKey);
}
/**
* @inheritDoc
*/ setDefaultHeader(header, value) {
this._proxy.setDefaultHeader(header, value);
return this;
}
/**
* @inheritDoc
*/ clearDefaultHeaders() {
this._proxy.clearDefaultHeaders();
return this;
}
/**
* Attempts to clone the provided value, if possible. Values that cannot be
* cloned (e.g. promises) will be simply returned.
*
* @param value The value to clone.
* @return The created clone, or the provided value if the value cannot be
* cloned.
*/ _clone(value) {
if (value !== null && typeof value === 'object' && !(value instanceof Promise)) {
return this._Helper.clone(value);
}
return value;
}
/**
* Check cache and if data isn’t available then make real request.
*
* @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 A promise that resolves to the response
* with body parsed as JSON.
*/ _requestWithCheckCache(method, url, data, options) {
const optionsWithDefault = this._prepareOptions(options, url);
if (optionsWithDefault.cache) {
const cachedData = this._getCachedData(method, url, data);
if (cachedData) {
return cachedData;
}
}
return this._request(method, url, data, optionsWithDefault);
}
/**
* Tests whether an ongoing or completed HTTP request for the specified URL
* and data is present in the internal cache and, if it is, the method
* returns a promise that resolves to the response body parsed as JSON.
*
* The method returns `null` if no such request is present in the
* cache.
*
* @param method The HTTP method used by the request.
* @param url The URL to which the request was made.
* @param data The data sent
* to the server with the request.
* @return {?Promise<HttpAgent~Response>} A promise that will resolve to the
* server response with the body parsed as JSON, or `null` if
* no such request is present in the cache.
*/ _getCachedData(method, url, data) {
const cacheKey = this.getCacheKey(method, url, data);
if (this._internalCacheOfPromises.has(cacheKey)) {
return this._internalCacheOfPromises.get(cacheKey).then((data)=>this._clone(data));
}
if (this._cache.has(cacheKey)) {
const cacheData = this._cache.get(cacheKey);
return Promise.resolve(cacheData);
}
return null;
}
/**
* Sends a new HTTP request using the specified method to the specified
* url. The request will carry the provided data as query parameters if the
* HTTP method is GET, but the data will be sent as request body for any
* other request method.
*
* @param method HTTP method to use.
* @param url The URL to which the request is sent.
* @param data The data sent
* with the request.
* @param options Optional request options.
* @return {Promise<HttpAgent~Response>} A promise that resolves to the response
* with the body parsed as JSON.
*/ _request(method, url, data, options) {
const cacheKey = this.getCacheKey(method, url, data);
const cachePromise = this._proxy.request(method, url, data, options).then((response)=>this._proxyResolved(response), (error)=>this._proxyRejected(error));
this._internalCacheOfPromises.set(cacheKey, cachePromise);
return cachePromise;
}
/**
* Handles successful completion of an HTTP request by the HTTP proxy.
*
* The method also updates the internal cookie storage with the cookies
* received from the server.
*
* @param {HttpAgent~Response} response Server response.
* @return {HttpAgent~Response} The post-processed server response.
*/ _proxyResolved(response) {
let agentResponse = {
status: response.status,
body: response.body,
params: response.params,
headers: response.headers,
headersRaw: response.headersRaw,
cached: false
};
const cacheKey = this.getCacheKey(agentResponse.params.method, agentResponse.params.url, agentResponse.params.data);
this._internalCacheOfPromises.delete(cacheKey);
if (this._proxy.haveToSetCookiesManually()) {
this._setCookiesFromResponse(agentResponse);
}
const { postProcessors, cache } = agentResponse.params.options;
if (Array.isArray(postProcessors)) {
for (const postProcessor of postProcessors){
agentResponse = postProcessor(agentResponse);
}
}
const pureResponse = this._cleanResponse(agentResponse);
if (cache) {
this._saveAgentResponseToCache(pureResponse);
}
return pureResponse;
}
/**
* Handles rejection of the HTTP request by the HTTP proxy. The method
* tests whether there are any remaining tries for the request, and if
* there are any, it attempts re-send the request.
*
* The method rejects the internal request promise if there are no tries
* left.
*
* @param error The error provided by the HttpProxy,
* carrying the error parameters, such as the request url, data,
* method, options and other useful data.
* @return {Promise<HttpAgent~Response>} A promise that will either resolve to a
* server's response (with the body parsed as JSON) if there are
* any tries left and the re-tried request succeeds, or rejects
* with an error containing details of the cause of the request's
* failure.
*/ _proxyRejected(error) {
const errorParams = error.getParams();
const method = errorParams.method;
const url = errorParams.url;
const data = errorParams.data;
const options = errorParams.options;
const isAborted = options.fetchOptions?.signal?.aborted || options.abortController?.signal.aborted;
if (!isAborted && options.repeatRequest > 0) {
options.repeatRequest--;
return this._request(method, url, data, options);
} else {
const cacheKey = this.getCacheKey(method, url, data);
this._internalCacheOfPromises.delete(cacheKey);
const errorName = errorParams.errorName;
const errorMessage = `${errorName}: ima.core.http.Agent:_proxyRejected: ${error.message}`;
const agentError = new GenericError(errorMessage, errorParams);
return Promise.reject(agentError);
}
}
/**
* Prepares the provided request options object by filling in missing
* options with default values and adding extra options used internally.
*
* @param options Optional request options.
* @return Request options with set filled-in
* default values for missing fields, and extra options used
* internally.
*/ _prepareOptions(options = {}, url) {
const composedOptions = {
...this._defaultRequestOptions,
...options,
postProcessors: [
...this._defaultRequestOptions?.postProcessors || [],
...options?.postProcessors || []
],
fetchOptions: {
...this._defaultRequestOptions?.fetchOptions,
...options?.fetchOptions,
headers: {
...this._defaultRequestOptions?.fetchOptions?.headers,
...options?.fetchOptions?.headers
}
}
};
if (composedOptions.fetchOptions?.credentials === 'include') {
// mock default browser behavior for server-side (sending cookie with a fetch request)
composedOptions.fetchOptions.headers.Cookie = this._cookie.getCookiesStringForCookieHeader(options.validateCookies ? url : undefined);
}
return composedOptions;
}
/**
* Generates cache key suffix for an HTTP request to the specified URL with
* the specified data.
*
* @param method The HTTP method used by the request.
* @param url The URL to which the request is sent.
* @param data The data sent
* with the request.
* @return The suffix of a cache key to use for a request to the
* specified URL, carrying the specified data.
*/ _getCacheKeySuffix(method, url, data) {
let dataQuery = '';
if (data) {
try {
dataQuery = JSON.stringify(data).replace(/<\/script/gi, '<\\/script');
} catch (error) {
console.warn('The provided data does not have valid JSON format', data);
}
}
return `${method}:${url}?${dataQuery}`;
}
/**
* Sets all cookies from the `Set-Cookie` response header to the
* cookie storage.
*
* @param agentResponse The response of the server.
*/ _setCookiesFromResponse(agentResponse) {
if (agentResponse.headersRaw) {
const receivedCookies = agentResponse.headersRaw.getSetCookie();
if (receivedCookies.length > 0) {
this._cookie.parseFromSetCookieHeader(receivedCookies, this._defaultRequestOptions.validateCookies ? agentResponse.params.url : undefined);
}
}
}
/**
* Saves the server response to the cache to be used as the result of the
* next request of the same properties.
*
* @param agentResponse The response of the server.
*/ _saveAgentResponseToCache(agentResponse) {
const cacheKey = this.getCacheKey(agentResponse.params.method, agentResponse.params.url, agentResponse.params.data);
agentResponse.cached = true;
this._cache.set(cacheKey, agentResponse, agentResponse.params.options.ttl);
agentResponse.cached = false;
}
/**
* Cleans cache response from data (abort controller, postProcessors), that cannot be persisted,
* before saving the data to the cache.
*/ _cleanResponse(response) {
/**
* Create copy of agentResponse without AbortController and AbortController signal and postProcessors.
* Setting agentResponse with AbortController or signal or postProcessors into cache would result in crash.
*/ const { signal, ...fetchOptions } = response.params.options.fetchOptions || {};
const { abortController, postProcessors, ...options } = response.params.options || {};
options.fetchOptions = fetchOptions;
const pureResponse = {
...response,
params: {
...response.params,
options: {
...options
}
}
};
if (pureResponse.params.options.keepSensitiveHeaders !== true) {
pureResponse.headers = {};
pureResponse.params.options.fetchOptions.headers = {};
delete pureResponse.headersRaw;
}
return pureResponse;
}
}
//# sourceMappingURL=HttpAgentImpl.js.map