/* 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} 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.} */ this._requestInterceptors = []; /** @type {Array.} */ 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} [init] * @param {Boolean} [parseErrorResponse] * @returns {Promise} */ 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} */ 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} */ 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} */ 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} */ 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; } }