lion/packages/ajax/src/Ajax.js

290 lines
9.4 KiB
JavaScript

/* eslint-disable consistent-return */
import {
acceptLanguageRequestInterceptor,
createXsrfRequestInterceptor,
createCacheInterceptors,
} from './interceptors/index.js';
import { AjaxFetchError } from './AjaxFetchError.js';
/**
* @typedef {import('../types/types.js').RequestInterceptor} RequestInterceptor
* @typedef {import('../types/types.js').CachedRequestInterceptor} CachedRequestInterceptor
* @typedef {import('../types/types.js').ResponseInterceptor} ResponseInterceptor
* @typedef {import('../types/types.js').CachedResponseInterceptor} CachedResponseInterceptor
* @typedef {import('../types/types.js').AjaxConfig} AjaxConfig
* @typedef {import('../types/types.js').CacheRequest} CacheRequest
* @typedef {import('../types/types.js').CacheResponse} CacheResponse
* @typedef {import('../types/types.js').CacheRequestExtension} CacheRequestExtension
* @typedef {import('../types/types.js').LionRequestInit} LionRequestInit
*/
/**
* @param {Response} response
* @returns {boolean}
*/
function isFailedResponse(response) {
return response.status >= 400 && response.status < 600;
}
/**
* A small wrapper around `fetch`.
- Allows globally registering request and response interceptors
- Throws on 4xx and 5xx status codes
- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and
deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
- Adds accept-language header to requests based on application language
- Adds XSRF header to request if the cookie is present
*/
export class Ajax {
/**
* @param {Partial<AjaxConfig>} config
*/
constructor(config = {}) {
/**
* @type {AjaxConfig}
* @private
*/
this.__config = {
addAcceptLanguage: true,
addCaching: false,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
xsrfTrustedOrigins: [],
jsonPrefix: '',
...config,
cacheOptions: {
getCacheIdentifier: () => '_default',
...config.cacheOptions,
},
};
/** @type {Array.<RequestInterceptor|CachedRequestInterceptor>} */
this._requestInterceptors = [];
/** @type {Array.<ResponseInterceptor|CachedResponseInterceptor>} */
this._responseInterceptors = [];
if (this.__config.addAcceptLanguage) {
this.addRequestInterceptor(acceptLanguageRequestInterceptor);
}
const { xsrfCookieName, xsrfHeaderName, xsrfTrustedOrigins } = this.__config;
if (xsrfCookieName && xsrfHeaderName && xsrfTrustedOrigins) {
this.addRequestInterceptor(
createXsrfRequestInterceptor(xsrfCookieName, xsrfHeaderName, xsrfTrustedOrigins),
);
}
// eslint-disable-next-line prefer-destructuring
const cacheOptions = /** @type {import('@lion/ajax').CacheOptionsWithIdentifier} */ (
this.__config.cacheOptions
);
if ((cacheOptions && cacheOptions.useCache) || this.__config.addCaching) {
if (cacheOptions.getCacheIdentifier) {
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
cacheOptions.getCacheIdentifier,
cacheOptions,
);
this.addRequestInterceptor(cacheRequestInterceptor);
this.addResponseInterceptor(cacheResponseInterceptor);
}
}
}
/**
* Configures the Ajax instance
* @param {AjaxConfig} config configuration for the Ajax instance
*/
set options(config) {
this.__config = config;
}
get options() {
return this.__config;
}
/** @param {RequestInterceptor} requestInterceptor */
addRequestInterceptor(requestInterceptor) {
this._requestInterceptors.push(requestInterceptor);
}
/** @param {RequestInterceptor} requestInterceptor */
removeRequestInterceptor(requestInterceptor) {
this._requestInterceptors = this._requestInterceptors.filter(
interceptor => interceptor !== requestInterceptor,
);
}
/** @param {ResponseInterceptor} responseInterceptor */
addResponseInterceptor(responseInterceptor) {
this._responseInterceptors.push(responseInterceptor);
}
/** @param {ResponseInterceptor} responseInterceptor */
removeResponseInterceptor(responseInterceptor) {
this._responseInterceptors = this._responseInterceptors.filter(
interceptor => interceptor !== responseInterceptor,
);
}
/**
* Fetch by calling the registered request and response interceptors.
*
* @param {RequestInfo} info
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
* @param {Boolean} [parseErrorResponse]
* @returns {Promise<Response>}
*/
async fetch(info, init, parseErrorResponse = false) {
const request = /** @type {CacheRequest} */ (new Request(info, { ...init }));
request.cacheOptions = init?.cacheOptions;
request.params = init?.params;
// run request interceptors, returning directly and skipping the network
const interceptedRequestOrResponse = await this.__interceptRequest(request);
if (interceptedRequestOrResponse instanceof Response) {
const response = /** @type {CacheResponse} */ (interceptedRequestOrResponse);
response.request = request;
if (isFailedResponse(interceptedRequestOrResponse)) {
throw new AjaxFetchError(
request,
response,
parseErrorResponse ? await this.__attemptParseFailedResponseBody(response) : undefined,
);
}
// prevent network request, return cached response
return response;
}
const response = /** @type {CacheResponse} */ (await fetch(interceptedRequestOrResponse));
response.request = interceptedRequestOrResponse;
const interceptedResponse = await this.__interceptResponse(response);
if (isFailedResponse(interceptedResponse)) {
throw new AjaxFetchError(
request,
response,
parseErrorResponse ? await this.__attemptParseFailedResponseBody(response) : undefined,
);
}
return interceptedResponse;
}
/**
* Fetch by calling the registered request and response
* interceptors. And supports JSON by:
* - Serializing request body as JSON
* - Deserializing response payload as JSON
* - Adding the correct Content-Type and Accept headers
*
* @template T
* @param {RequestInfo} info
* @param {LionRequestInit} [init]
* @returns {Promise<{ response: Response, body: string | T }>}
*/
async fetchJson(info, init) {
const lionInit = {
...init,
headers: {
...init?.headers,
accept: 'application/json',
},
};
if (lionInit?.body) {
// eslint-disable-next-line no-param-reassign
lionInit.headers['content-type'] = 'application/json';
lionInit.body = JSON.stringify(lionInit.body);
}
// typecast LionRequestInit back to RequestInit
const jsonInit = /** @type {RequestInit} */ (lionInit);
const response = await this.fetch(info, jsonInit, true);
const body = await this.__parseBody(response);
return { response, body };
}
/**
* @template T
* @param {Response} response
* @returns {Promise<string|T>}
*/
async __parseBody(response) {
// clone the response, so the consumer can also read it out manually as well
let responseText = await response.clone().text();
const { jsonPrefix } = this.__config;
if (typeof jsonPrefix === 'string' && responseText.startsWith(jsonPrefix)) {
responseText = responseText.substring(jsonPrefix.length);
}
let body = responseText;
if (
body.length &&
(!response.headers.get('content-type') ||
response.headers.get('content-type')?.includes('json'))
) {
try {
body = JSON.parse(responseText);
} catch (error) {
throw new Error(`Failed to parse response from ${response.url} as JSON.`);
}
} else {
body = responseText;
}
return body;
}
/**
* @param {Response} response
* @returns {Promise<string|Object|undefined>}
*/
async __attemptParseFailedResponseBody(response) {
let body;
try {
body = await this.__parseBody(response);
} catch (e) {
// no need to throw/log, failed responses often don't have a body
}
return body;
}
/**
* @param {Request} request
* @returns {Promise<Request | Response>}
*/
async __interceptRequest(request) {
// run request interceptors, returning directly and skipping the network
// if a interceptor returns a Response
let interceptedRequest = request;
for (const intercept of this._requestInterceptors) {
// In this instance we actually do want to await for each sequence
// eslint-disable-next-line no-await-in-loop
const interceptedRequestOrResponse = await intercept(interceptedRequest);
if (interceptedRequestOrResponse instanceof Request) {
interceptedRequest = interceptedRequestOrResponse;
} else {
return this.__interceptResponse(interceptedRequestOrResponse);
}
}
return interceptedRequest;
}
/**
* @param {Response} response
* @returns {Promise<Response>}
*/
async __interceptResponse(response) {
let interceptedResponse = response;
for (const intercept of this._responseInterceptors) {
// In this instance we actually do want to await for each sequence
// eslint-disable-next-line no-await-in-loop
interceptedResponse = await intercept(/** @type {CacheResponse} */ (interceptedResponse));
}
return interceptedResponse;
}
}