diff --git a/.changeset/blue-shoes-shave.md b/.changeset/blue-shoes-shave.md new file mode 100644 index 0000000000..6fa4eda2db --- /dev/null +++ b/.changeset/blue-shoes-shave.md @@ -0,0 +1,5 @@ +--- +'@lion/ajax': minor +--- + +BREAKING CHANGE: We no longer use axios! Our ajax package is now a thin wrapper around Fetch. The API has changed completely. You will need a fetch polyfill for IE11. diff --git a/.storybook/main.js b/.storybook/main.js index 81b7fdd967..eb21ffd6cd 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -3,7 +3,7 @@ const path = require('path'); module.exports = { stories: [ - '../{packages,packages-node}/*/README.md', + '../{packages,packages-node}/!(ajax)*/README.md', '../{packages,packages-node}/*/docs/*.md', '../{packages,packages-node}/*/docs/!(assets)**/*.md', '../packages/helpers/*/README.md', diff --git a/packages/ajax/README.md b/packages/ajax/README.md index 4ce89d8213..3e610f135c 100644 --- a/packages/ajax/README.md +++ b/packages/ajax/README.md @@ -1,198 +1,99 @@ -# Ajax - -`ajax` is the global manager for handling all ajax requests. -It is a promise based system for fetching data, based on [axios](https://github.com/axios/axios) +[//]: # 'AUTO INSERT HEADER PREPUBLISH' -```js script -import { html } from '@lion/core'; -import { ajax } from './src/ajax.js'; -import { AjaxClass } from './src/AjaxClass.js'; - -export default { - title: 'Others/Ajax', -}; -``` +# Ajax -## Features +`ajax` is a small wrapper around `fetch` which: -- only JS functions, no (unnecessarily expensive) web components -- supports GET, POST, PUT, DELETE, REQUEST, PATCH and HEAD methods -- can be used with or without XSRF token +- Allows globally registering request and response interceptors +- Throws on 4xx and 5xx status codes +- Prevents network request if a request interceptor returns a response +- Supports a JSON request which automatically encodes/decodes body request and response payload as JSON +- Adds accept-language header to requests based on application language +- Adds XSRF header to request if the cookie is present ## How to use ### Installation -```bash +```sh npm i --save @lion/ajax ``` -```js -import { ajax, AjaxClass } from '@lion/ajax'; -``` +### Relation to fetch -### Example +`ajax` delegates all requests to fetch. `ajax.request` and `ajax.requestJson` have the same function signature as `window.fetch`, you can use any online resource to learn more about fetch. [MDN](http://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) is a great start. + +### Example requests + +#### GET request ```js import { ajax } from '@lion/ajax'; -ajax.get('data.json').then(response => console.log(response)); -``` - -### Performing requests - -Performing a `GET` request: - -```js preview-story -export const performingGetRequests = () => html` - -`; +const response = await ajax.request('/api/users'); +const users = await response.json(); ``` -To post data to the server, pass the data as the second argument in the `POST` request: +#### POST request ```js -const body = { - ant: { - type: 'insect', - limbs: 6, - }, -}; -ajax - .post('zooApi/animals/addAnimal', body) - .then(response => { - console.log(`POST successful: ${response.status} ${response.statusText}`); - }) - .catch(error => { - console.log(error); - }); +import { ajax } from '@lion/ajax'; + +const response = await ajax.request('/api/users', { + method: 'POST', + body: JSON.stringify({ username: 'steve' }), +}); +const newUser = await response.json(); ``` -## Configuration +### JSON requests -### JSON prefix +We usually deal with JSON requests and responses. With `requestJson` you don't need to specifically stringify the request body or parse the response body: -The called API might add a JSON prefix to the response in order to prevent hijacking. -The prefix renders the string syntactically invalid as a script so that it cannot be hijacked. -This prefix should be stripped before parsing the string as JSON. -Pass the prefix with the `jsonPrefix` option. +#### GET JSON request ```js -const myAjax = new AjaxClass({ jsonPrefix: ")]}'," }); -myAjax - .get('./packages/ajax/docs/assets/data.json') - .then(response => { - console.log(response.data); - }) - .catch(error => { - console.log(error); - }); -``` +import { ajax } from '@lion/ajax'; -### Additional headers - -Add additional headers to the requests with the `headers` option. - -```js preview-story -export const additionalHeaders = () => html` - -`; +const { response, body } = await ajax.requestJson('/api/users'); ``` -When executing the request above, check the Network tab in the Browser's dev tools and look for the Request Header on the GET call. - -### Cancelable Request - -It is possible to make an Ajax request cancelable, and then call `cancel()` to make the request provide a custom error once fired. - -```js preview-story -export const cancelableRequests = () => html` - -`; -``` +#### POST JSON request + +```js +import { ajax } from '@lion/ajax'; -### Cancel concurrent requests - -You can cancel concurrent requests with the `cancelPreviousOnNewRequest` option. - -```js preview-story -export const cancelConcurrentRequests = () => html` - -`; +const { response, body } = await ajax.requestJson('/api/users', { + method: 'POST', + body: { username: 'steve' }, +}); ``` -## Considerations +### Error handling + +Different from fetch, `ajax` throws when the server returns a 4xx or 5xx, returning the request and response: + +```js +import { ajax } from '@lion/ajax'; + +try { + const users = await ajax.requestJson('/api/users'); +} catch (error) { + if (error.response) { + if (error.response.status === 400) { + // handle a specific status code, for example 400 bad request + } else { + console.error(error); + } + } else { + // an error happened before receiving a response, ex. an incorrect request or network error + console.error(error); + } +} +``` -Due to a [bug in axios](https://github.com/axios/axios/issues/385) options may leak in to other instances. -So please avoid setting global options in axios. Interceptors have no issues. +## Fetch Polyfill -## Future plans +For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application. -- Eventually we want to remove axios and replace it with [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) -- This wrapper exist to prevent this switch from causing breaking changes for our users +[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests) diff --git a/packages/ajax/docs/assets/data.json b/packages/ajax/docs/assets/data.json deleted file mode 100644 index c75dffd5c1..0000000000 --- a/packages/ajax/docs/assets/data.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "animals": { - "cow": { - "type": "mammal", - "limbs": 4 - }, - "frog": { - "type": "amphibian", - "limbs": 4 - }, - "snake": { - "type": "reptile", - "limbs": 0 - } - } -} diff --git a/packages/ajax/index.js b/packages/ajax/index.js index 72e2d0c701..54d09a030e 100644 --- a/packages/ajax/index.js +++ b/packages/ajax/index.js @@ -1,11 +1,9 @@ export { ajax, setAjax } from './src/ajax.js'; - -export { AjaxClass } from './src/AjaxClass.js'; +export { AjaxClient } from './src/AjaxClient.js'; +export { AjaxClientFetchError } from './src/AjaxClientFetchError.js'; export { - cancelInterceptorFactory, - cancelPreviousOnNewRequestInterceptorFactory, - addAcceptLanguageHeaderInterceptorFactory, + acceptLanguageRequestInterceptor, + createXSRFRequestInterceptor, + getCookie, } from './src/interceptors.js'; - -export { jsonPrefixTransformerFactory } from './src/transformers.js'; diff --git a/packages/ajax/package.json b/packages/ajax/package.json index 49cf527c62..8d21214be6 100644 --- a/packages/ajax/package.json +++ b/packages/ajax/package.json @@ -1,7 +1,7 @@ { "name": "@lion/ajax", "version": "0.5.15", - "description": "Thin wrapper around axios to allow for custom interceptors", + "description": "Thin wrapper around fetch with support for interceptors.", "license": "MIT", "author": "ing-bank", "homepage": "https://github.com/ing-bank/lion/", @@ -29,18 +29,17 @@ "prepublishOnly": "../../scripts/npm-prepublish.js", "test": "cd ../../ && npm run test:browser -- --group ajax" }, - "sideEffects": false, - "dependencies": { - "@bundled-es-modules/axios": "0.18.1", - "@lion/core": "0.13.8", - "singleton-manager": "1.2.1" - }, "keywords": [ "ajax", + "fetch", + "http", "lion", "web-components" ], "publishConfig": { "access": "public" + }, + "exports": { + ".": "./index.js" } } diff --git a/packages/ajax/src/AjaxClass.js b/packages/ajax/src/AjaxClass.js deleted file mode 100644 index 2ce97cc9a6..0000000000 --- a/packages/ajax/src/AjaxClass.js +++ /dev/null @@ -1,254 +0,0 @@ -// @ts-ignore no types for bundled-es-modules/axios -import { axios } from '@bundled-es-modules/axios'; -import { - cancelInterceptorFactory, - cancelPreviousOnNewRequestInterceptorFactory, - addAcceptLanguageHeaderInterceptorFactory, -} from './interceptors.js'; -import { jsonPrefixTransformerFactory } from './transformers.js'; - -/** - * @typedef {(config: {[key:string]: ?}) => { transformRequest: (data: string, headers: { [key: string]: any; }) => any;}} RequestInterceptor - * @typedef {(config: {[key:string]: ?}) => Response} ResponseInterceptor - * - * @typedef {Object} AjaxConfig - * @property {string} [jsonPrefix] prefixing the JSON string in this manner is used to help - * prevent JSON Hijacking. The prefix renders the string syntactically invalid as a script so - * that it cannot be hijacked. This prefix should be stripped before parsing the string as JSON. - * @property {string} [lang] language - * @property {boolean} [languageHeader] the Accept-Language request HTTP header advertises - * which languages the client is able to understand, and which locale variant is preferred. - * @property {boolean} [cancelable] if request can be canceled - * @property {boolean} [cancelPreviousOnNewRequest] prevents concurrent requests - */ - -/** - * `AjaxClass` creates the singleton instance {@link:ajax}. It is a promise based system for - * fetching data, based on [axios](https://github.com/axios/axios). - */ -export class AjaxClass { - /** - * @property {Object} proxy the axios instance that is bound to the AjaxClass instance - */ - - /** - * @param {AjaxConfig} [config] configuration for the AjaxClass instance - */ - constructor(config) { - this.__config = { - lang: document.documentElement.getAttribute('lang'), - languageHeader: true, - cancelable: false, - cancelPreviousOnNewRequest: false, - ...config, - }; - this.proxy = axios.create(this.__config); - this.__setupInterceptors(); - - /** @type {Array.} */ - this.requestInterceptors = []; - /** @type {Array.} */ - this.requestErrorInterceptors = []; - /** @type {Array.} */ - this.responseErrorInterceptors = []; - /** @type {Array.} */ - this.responseInterceptors = []; - - /** @type {Array.<(data: string, headers?: {[key:string]: ?}) => string>} */ - this.requestDataTransformers = []; - /** @type {Array.<(data: string, headers?: {[key:string]: ?}) => string>} */ - this.requestDataErrorTransformers = []; - /** @type {Array.<(data: string, headers?: {[key:string]: ?}) => string>} */ - this.responseDataErrorTransformers = []; - /** @type {Array.<(data: string, headers?: {[key:string]: ?}) => string>} */ - this.responseDataTransformers = []; - - this.__isInterceptorsSetup = false; - - if (this.__config.languageHeader) { - // @ts-ignore butchered something here.. - this.requestInterceptors.push(addAcceptLanguageHeaderInterceptorFactory(this.__config.lang)); - } - - if (this.__config.cancelable) { - // @ts-ignore butchered something here.. - this.requestInterceptors.push(cancelInterceptorFactory(this)); - } - - if (this.__config.cancelPreviousOnNewRequest) { - // @ts-ignore butchered something here.. - this.requestInterceptors.push(cancelPreviousOnNewRequestInterceptorFactory()); - } - - if (this.__config.jsonPrefix) { - const transformer = jsonPrefixTransformerFactory(this.__config.jsonPrefix); - this.responseDataTransformers.push(transformer); - } - } - - /** - * Sets the config for the instance - * @param {AjaxConfig} config configuration for the AjaxClass instance - */ - set options(config) { - // @ts-ignore butchered something here.. - this.__config = config; - } - - get options() { - // @ts-ignore butchered something here.. - return this.__config; - } - - /** - * Dispatches a request - * @see https://github.com/axios/axios - * @param {string} url - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - request(url, config) { - return this.proxy.request.apply(this, [url, { ...this.__config, ...config }]); - } - - /** @param {string} msg */ - // eslint-disable-next-line class-methods-use-this, no-unused-vars - cancel(msg) {} - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'get' predefined - * @param {string} url the endpoint location - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - get(url, config) { - return this.proxy.get.apply(this, [url, { ...this.__config, ...config }]); - } - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'delete' predefined - * @param {string} url the endpoint location - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - delete(url, config) { - return this.proxy.delete.apply(this, [url, { ...this.__config, ...config }]); - } - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'head' predefined - * @param {string} url the endpoint location - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - head(url, config) { - return this.proxy.head.apply(this, [url, { ...this.__config, ...config }]); - } - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'options' predefined - * @param {string} url the endpoint location - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - // options(url, config) { - // return this.proxy.options.apply(this, [url, { ...this.__config, ...config }]); - // } - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'post' predefined - * @param {string} url the endpoint location - * @param {Object} [data] the data to be sent to the endpoint - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - post(url, data, config) { - return this.proxy.post.apply(this, [url, data, { ...this.__config, ...config }]); - } - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'put' predefined - * @param {string} url the endpoint location - * @param {Object} [data] the data to be sent to the endpoint - * @param {{[key:string]: ?}} [config] the config specific for this request - * @returns {?} - */ - put(url, data, config) { - return this.proxy.put.apply(this, [url, data, { ...this.__config, ...config }]); - } - - /** - * Dispatches a {@link AxiosRequestConfig} with method 'patch' predefined - * @see https://github.com/axios/axios (Request Config) - * @param {string} url the endpoint location - * @param {Object} [data] the data to be sent to the endpoint - * @param {Object} [config] the config specific for this request. - * @returns {?} - */ - patch(url, data, config) { - return this.proxy.patch.apply(this, [url, data, { ...this.__config, ...config }]); - } - - __setupInterceptors() { - this.proxy.interceptors.request.use( - /** @param {{[key:string]: unknown}} config */ config => { - const configWithTransformers = this.__setupTransformers(config); - // @ts-ignore I dont know.... - return this.requestInterceptors.reduce((c, i) => i(c), configWithTransformers); - }, - /** @param {Error} error */ error => { - this.requestErrorInterceptors.forEach(i => i(error)); - return Promise.reject(error); - }, - ); - - this.proxy.interceptors.response.use( - /** - * @param {Response} response - */ - response => this.responseInterceptors.reduce((r, i) => i(r), response), - /** @param {Error} error */ error => { - this.responseErrorInterceptors.forEach(i => i(error)); - return Promise.reject(error); - }, - ); - } - - /** @param {{[key:string]: ?}} config */ - __setupTransformers(config) { - const axiosTransformRequest = config.transformRequest[0]; - const axiosTransformResponse = config.transformResponse[0]; - return { - ...config, - /** - * @param {string} data - * @param {{[key:string]: ?}} headers - */ - transformRequest: (data, headers) => { - try { - const ourData = this.requestDataTransformers.reduce((d, t) => t(d, headers), data); - // axios does a lot of smart things with the request that people rely on - // and must be the last request data transformer to do this job - return axiosTransformRequest(ourData, headers); - } catch (error) { - this.requestDataErrorTransformers.forEach(t => t(error)); - throw error; - } - }, - /** - * @param {string} data - */ - transformResponse: data => { - try { - // axios does a lot of smart things with the response that people rely on - // and must be the first response data transformer to do this job - const axiosData = axiosTransformResponse(data); - return this.responseDataTransformers.reduce((d, t) => t(d), axiosData); - } catch (error) { - this.responseDataErrorTransformers.forEach(t => t(error)); - throw error; - } - }, - }; - } -} diff --git a/packages/ajax/src/AjaxClient.js b/packages/ajax/src/AjaxClient.js new file mode 100644 index 0000000000..38354d7c99 --- /dev/null +++ b/packages/ajax/src/AjaxClient.js @@ -0,0 +1,174 @@ +/* eslint-disable consistent-return */ +import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js'; +import { AjaxClientFetchError } from './AjaxClientFetchError.js'; + +/** + * @typedef {Object} AjaxClientConfig configuration for the AjaxClient instance + * @property {boolean} [addAcceptLanguage] the Accept-Language request HTTP header advertises + * which languages the client is able to understand, and which locale variant is preferred. + * @property {string|null} [xsrfCookieName] name of the XSRF cookie to read from + * @property {string|null} [xsrfHeaderName] name of the XSRF header to set + * @property {string} [jsonPrefix] the json prefix to use when fetching json (if any) + */ + +/** + * Intercepts a Request before fetching. Must return an instance of Request or Response. + * If a Respone is returned, the network call is skipped and it is returned as is. + * @typedef {(request: Request) => Promise} RequestInterceptor + */ + +/** + * Intercepts a Response before returning. Must return an instance of Response. + * @typedef {(response: Response) => Promise} ResponseInterceptor + */ + +/** + * Overrides the body property to also allow javascript objects + * as they get string encoded automatically + * @typedef {import('../types/ajaxClientTypes').LionRequestInit} LionRequestInit + */ + +/** + * HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which + * intercept request and responses, for example to add authorization headers or logging. A + * request can also be prevented from reaching the network at all by returning the Response directly. + */ +export class AjaxClient { + /** + * @param {AjaxClientConfig} config + */ + constructor(config = {}) { + const { + addAcceptLanguage = true, + xsrfCookieName = 'XSRF-TOKEN', + xsrfHeaderName = 'X-XSRF-TOKEN', + jsonPrefix, + } = config; + + /** @type {string | undefined} */ + this._jsonPrefix = jsonPrefix; + /** @type {RequestInterceptor[]} */ + this._requestInterceptors = []; + /** @type {ResponseInterceptor[]} */ + this._responseInterceptors = []; + + if (addAcceptLanguage) { + this.addRequestInterceptor(acceptLanguageRequestInterceptor); + } + + if (xsrfCookieName && xsrfHeaderName) { + this.addRequestInterceptor(createXSRFRequestInterceptor(xsrfCookieName, xsrfHeaderName)); + } + } + + /** @param {RequestInterceptor} requestInterceptor */ + addRequestInterceptor(requestInterceptor) { + this._requestInterceptors.push(requestInterceptor); + } + + /** @param {RequestInterceptor} requestInterceptor */ + removeRequestInterceptor(requestInterceptor) { + const indexOf = this._requestInterceptors.indexOf(requestInterceptor); + if (indexOf !== -1) { + this._requestInterceptors.splice(indexOf); + } + } + + /** @param {ResponseInterceptor} responseInterceptor */ + addResponseInterceptor(responseInterceptor) { + this._responseInterceptors.push(responseInterceptor); + } + + /** @param {ResponseInterceptor} responseInterceptor */ + removeResponseInterceptor(responseInterceptor) { + const indexOf = this._responseInterceptors.indexOf(responseInterceptor); + if (indexOf !== -1) { + this._responseInterceptors.splice(indexOf, 1); + } + } + + /** + * Makes a fetch request, calling the registered fetch request and response + * interceptors. + * + * @param {RequestInfo} info + * @param {RequestInit} [init] + * @returns {Promise} + */ + async request(info, init) { + const request = new Request(info, init); + + // 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 interceptedRequestOrResponse; + } + } + + const response = await fetch(interceptedRequest); + + 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(interceptedResponse); + } + + if (interceptedResponse.status >= 400 && interceptedResponse.status < 600) { + throw new AjaxClientFetchError(request, interceptedResponse); + } + return interceptedResponse; + } + + /** + * Makes a fetch request, calling the registered fetch request and response + * interceptors. Encodes/decodes the request and response body as JSON. + * + * @param {RequestInfo} info + * @param {LionRequestInit} [init] + * @template T + * @returns {Promise<{ response: Response, body: T }>} + */ + async requestJson(info, init) { + const lionInit = { + ...init, + headers: { + ...(init && init.headers), + accept: 'application/json', + }, + }; + + if (lionInit && lionInit.body) { + // eslint-disable-next-line no-param-reassign + lionInit.headers['content-type'] = 'application/json'; + lionInit.body = JSON.stringify(lionInit.body); + } + + // Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit + const jsonInit = /** @type {RequestInit} */ (lionInit); + const response = await this.request(info, jsonInit); + let responseText = await response.text(); + + if (typeof this._jsonPrefix === 'string') { + if (responseText.startsWith(this._jsonPrefix)) { + responseText = responseText.substring(this._jsonPrefix.length); + } + } + + try { + return { + response, + body: JSON.parse(responseText), + }; + } catch (error) { + throw new Error(`Failed to parse response from ${response.url} as JSON.`); + } + } +} diff --git a/packages/ajax/src/AjaxClientFetchError.js b/packages/ajax/src/AjaxClientFetchError.js new file mode 100644 index 0000000000..bc66f31c80 --- /dev/null +++ b/packages/ajax/src/AjaxClientFetchError.js @@ -0,0 +1,11 @@ +export class AjaxClientFetchError extends Error { + /** + * @param {Request} request + * @param {Response} response + */ + constructor(request, response) { + super(`Fetch request to ${request.url} failed.`); + this.request = request; + this.response = response; + } +} diff --git a/packages/ajax/src/ajax.js b/packages/ajax/src/ajax.js index 3bcf1cf54c..288a67d2dd 100644 --- a/packages/ajax/src/ajax.js +++ b/packages/ajax/src/ajax.js @@ -1,17 +1,13 @@ -import { singletonManager } from 'singleton-manager'; -import { AjaxClass } from './AjaxClass.js'; +import { AjaxClient } from './AjaxClient.js'; -/** - * - */ -export let ajax = singletonManager.get('@lion/ajax::ajax::0.3.x') || new AjaxClass(); // eslint-disable-line import/no-mutable-exports +export let ajax = new AjaxClient(); // eslint-disable-line import/no-mutable-exports /** * setAjax allows the Application Developer to override the globally used instance of {@link:ajax}. * All interactions with {@link:ajax} after the call to setAjax will use this new instance * (so make sure to call this method before dependant code using {@link:ajax} is ran and this * method is not called by any of your (indirect) dependencies.) - * @param {AjaxClass} newAjax the globally used instance of {@link:ajax}. + * @param {AjaxClient} newAjax the globally used instance of {@link:ajax}. */ export function setAjax(newAjax) { ajax = newAjax; diff --git a/packages/ajax/src/interceptors.js b/packages/ajax/src/interceptors.js index cc4380314b..f0eb63be13 100644 --- a/packages/ajax/src/interceptors.js +++ b/packages/ajax/src/interceptors.js @@ -1,57 +1,52 @@ -// @ts-ignore no types for bundled-es-modules/axios -import { axios } from '@bundled-es-modules/axios'; +/** + * @typedef {import('./AjaxClient').RequestInterceptor} RequestInterceptor + */ /** - * @param {string} [lang] - * @return {(config: {[key:string]: ?}) => {[key:string]: ?}} + * @param {string} name the cookie name + * @param {Document | { cookie: string }} _document overwriteable for testing + * @returns {string | null} */ -export function addAcceptLanguageHeaderInterceptorFactory(lang) { - return /** @param {{[key:string]: ?}} config */ config => { - const result = config; - if (typeof lang === 'string' && lang !== '') { - if (typeof result.headers !== 'object') { - result.headers = {}; - } - const withLang = { headers: { 'Accept-Language': lang, ...result.headers } }; - return { ...result, ...withLang }; - } - return result; - }; +export function getCookie(name, _document = document) { + const match = _document.cookie.match(new RegExp(`(^|;\\s*)(${name})=([^;]*)`)); + return match ? decodeURIComponent(match[3]) : null; } /** - * @param {import('./AjaxClass').AjaxClass} ajaxInstance - * @return {(config: {[key:string]: ?}) => {[key:string]: ?}} + * Transforms a request, adding an accept-language header with the current application's locale + * if it has not already been set. + * @type {RequestInterceptor} */ -export function cancelInterceptorFactory(ajaxInstance) { - /** @type {unknown[]} */ - const cancelSources = []; - return /** @param {{[key:string]: ?}} config */ config => { - const source = axios.CancelToken.source(); - cancelSources.push(source); - /* eslint-disable-next-line no-param-reassign */ - ajaxInstance.cancel = (message = 'Operation canceled by the user.') => { - // @ts-ignore axios is untyped so we don't know the type for the source - cancelSources.forEach(s => s.cancel(message)); - }; - return { ...config, cancelToken: source.token }; - }; +export async function acceptLanguageRequestInterceptor(request) { + if (!request.headers.has('accept-language')) { + let locale = document.documentElement.lang || 'en'; + if (document.documentElement.getAttribute('data-localize-lang')) { + locale = document.documentElement.getAttribute('data-localize-lang') || 'en'; + } + request.headers.set('accept-language', locale); + } + return request; } /** - * @return {(config: {[key:string]: ?}) => {[key:string]: ?}} + * Creates a request transformer that adds a XSRF header for protecting + * against cross-site request forgery. + * @param {string} cookieName the cookie name + * @param {string} headerName the header name + * @param {Document | { cookie: string }} _document overwriteable for testing + * @returns {RequestInterceptor} */ -export function cancelPreviousOnNewRequestInterceptorFactory() { - // @ts-ignore axios is untyped so we don't know the type for the source - let prevCancelSource; - return /** @param {{[key:string]: ?}} config */ config => { - // @ts-ignore axios is untyped so we don't know the type for the source - if (prevCancelSource) { - // @ts-ignore - prevCancelSource.cancel('Concurrent requests not allowed.'); +export function createXSRFRequestInterceptor(cookieName, headerName, _document = document) { + /** + * @type {RequestInterceptor} + */ + async function xsrfRequestInterceptor(request) { + const xsrfToken = getCookie(cookieName, _document); + if (xsrfToken) { + request.headers.set(headerName, xsrfToken); } - const source = axios.CancelToken.source(); - prevCancelSource = source; - return { ...config, cancelToken: source.token }; - }; + return request; + } + + return xsrfRequestInterceptor; } diff --git a/packages/ajax/src/transformers.js b/packages/ajax/src/transformers.js deleted file mode 100644 index d5a43b44bb..0000000000 --- a/packages/ajax/src/transformers.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @param {string} prefix - */ -export function jsonPrefixTransformerFactory(prefix) { - return /** @param {string} data */ data => { - let result = data; - if (typeof result === 'string') { - if (prefix.length > 0 && result.indexOf(prefix) === 0) { - result = result.substring(prefix.length); - } - try { - result = JSON.parse(result); - } catch (e) { - /* ignore to allow non-JSON responses */ - } - } - return result; - }; -} diff --git a/packages/ajax/test/AjaxClass.interceptors.test.js b/packages/ajax/test/AjaxClass.interceptors.test.js deleted file mode 100644 index 9df4893ea2..0000000000 --- a/packages/ajax/test/AjaxClass.interceptors.test.js +++ /dev/null @@ -1,144 +0,0 @@ -import { expect } from '@open-wc/testing'; -import sinon from 'sinon'; - -import { AjaxClass } from '../src/AjaxClass.js'; - -describe('AjaxClass interceptors', () => { - /** @type {import('sinon').SinonFakeServer} */ - let server; - - /** - * @param {Object} [cfg] configuration for the AjaxClass instance - * @param {string} cfg.jsonPrefix prefixing the JSON string in this manner is used to help - * prevent JSON Hijacking. The prefix renders the string syntactically invalid as a script so - * that it cannot be hijacked. This prefix should be stripped before parsing the string as JSON. - * @param {string} cfg.lang language - * @param {boolean} cfg.languageHeader the Accept-Language request HTTP header advertises - * which languages the client is able to understand, and which locale variant is preferred. - * @param {boolean} cfg.cancelable if request can be canceled - * @param {boolean} cfg.cancelPreviousOnNewRequest prevents concurrent requests - */ - function getInstance(cfg) { - return new AjaxClass(cfg); - } - - beforeEach(() => { - server = sinon.fakeServer.create({ autoRespond: true }); - }); - - afterEach(() => { - server.restore(); - }); - - describe('use cases', () => { - it('can be added on a class for all instances', () => { - ['requestInterceptors', 'responseInterceptors'].forEach(type => { - const myInterceptor = () => {}; - class MyApi extends AjaxClass { - constructor() { - super(); - this[type] = [...this[type], myInterceptor]; - } - } - const ajaxWithout = getInstance(); - const ajaxWith = new MyApi(); - expect(ajaxWithout[type]).to.not.include(myInterceptor); - expect(ajaxWith[type]).to.include(myInterceptor); - }); - }); - - it('can be added per instance without changing the class', () => { - ['requestInterceptors', 'responseInterceptors'].forEach(type => { - const myInterceptor = () => {}; - const ajaxWithout = getInstance(); - const ajaxWith = getInstance(); - ajaxWith[type].push(myInterceptor); - expect(ajaxWithout[type]).to.not.include(myInterceptor); - expect(ajaxWith[type]).to.include(myInterceptor); - }); - }); - - it('can be removed after request', async () => { - await Promise.all( - ['requestInterceptors', 'responseInterceptors'].map(async type => { - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{}', - ]); - - const myInterceptor = sinon.spy(foo => foo); - - const ajax = getInstance(); - - ajax[type].push(myInterceptor); - await ajax.get('data.json'); - - ajax[type] = ajax[type].filter(/** @param {?} item */ item => item !== myInterceptor); - await ajax.get('data.json'); - - expect(myInterceptor.callCount).to.eql(1); - }), - ); - }); - - it('has access to provided instance config(options) on requestInterceptors', async () => { - server.respondWith('GET', 'data.json', [200, { 'Content-Type': 'application/json' }, '{}']); - const ajax = getInstance(); - // @ts-ignore setting a prop that isn't existing on options - ajax.options.myCustomValue = 'foo'; - let customValueAccess = false; - const myInterceptor = /** @param {{[key: string]: ?}} config */ config => { - customValueAccess = config.myCustomValue === 'foo'; - return config; - }; - // @ts-ignore butchered something here.. - ajax.requestInterceptors.push(myInterceptor); - await ajax.get('data.json'); - expect(customValueAccess).to.eql(true); - }); - }); - - describe('requestInterceptors', () => { - it('allow to intercept request to change config', async () => { - server.respondWith('POST', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "post" }', - ]); - server.respondWith('PUT', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "put" }', - ]); - const enforcePutInterceptor = /** @param {{[key: string]: ?}} config */ config => ({ - ...config, - method: 'PUT', - }); - const myAjax = getInstance(); - // @ts-ignore butchered something here.. - myAjax.requestInterceptors.push(enforcePutInterceptor); - const response = await myAjax.post('data.json'); - expect(response.data).to.deep.equal({ method: 'put' }); - }); - }); - - describe('responseInterceptors', () => { - it('allow to intercept response to change data', async () => { - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - const addDataInterceptor = /** @param {{[key: string]: ?}} config */ config => ({ - ...config, - data: { ...config.data, foo: 'bar' }, - }); - const myAjax = getInstance(); - // @ts-ignore I probably butchered the types here or adding data like above is simply not allowed in Response objects - myAjax.responseInterceptors.push(addDataInterceptor); - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get', foo: 'bar' }); - }); - }); -}); diff --git a/packages/ajax/test/AjaxClass.languages.test.js b/packages/ajax/test/AjaxClass.languages.test.js deleted file mode 100644 index c17108cb2d..0000000000 --- a/packages/ajax/test/AjaxClass.languages.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { expect, aTimeout } from '@open-wc/testing'; -import sinon from 'sinon'; - -import { AjaxClass } from '../src/AjaxClass.js'; - -describe('AjaxClass languages', () => { - /** @type {import('sinon').SinonFakeXMLHttpRequestStatic} */ - let fakeXhr; - /** @type {import('sinon').SinonFakeXMLHttpRequest[]} */ - let requests; - - beforeEach(() => { - fakeXhr = sinon.useFakeXMLHttpRequest(); - requests = []; - fakeXhr.onCreate = xhr => { - requests.push(xhr); - }; - }); - - afterEach(() => { - fakeXhr.restore(); - document.documentElement.lang = 'en-GB'; - }); - - it('sets "Accept-Language" header to "en-GB" for one request if ', async () => { - document.documentElement.lang = 'en-GB'; - const req = new AjaxClass(); - req.get('data.json'); - await aTimeout(0); - expect(requests.length).to.equal(1); - expect(requests[0].requestHeaders['Accept-Language']).to.equal('en-GB'); - }); - - it('sets "Accept-Language" header to "en-GB" for multiple subsequent requests if ', async () => { - document.documentElement.lang = 'en-GB'; - const req = new AjaxClass(); - req.get('data1.json'); - req.post('data2.json'); - req.put('data3.json'); - req.delete('data4.json'); - await aTimeout(0); - expect(requests.length).to.equal(4); - requests.forEach(request => { - expect(request.requestHeaders['Accept-Language']).to.equal('en-GB'); - }); - }); - - it('sets "Accept-Language" header to "nl-NL" for one request if ', async () => { - document.documentElement.lang = 'nl-NL'; - const req = new AjaxClass(); - req.get('data.json'); - await aTimeout(0); - expect(requests.length).to.equal(1); - expect(requests[0].requestHeaders['Accept-Language']).to.equal('nl-NL'); - }); - - it('sets "Accept-Language" header to "nl-NL" for multiple subsequent requests if ', async () => { - document.documentElement.lang = 'nl-NL'; - const req = new AjaxClass(); - req.get('data1.json'); - req.post('data2.json'); - req.put('data3.json'); - req.delete('data4.json'); - await aTimeout(0); - expect(requests.length).to.equal(4); - requests.forEach(request => { - expect(request.requestHeaders['Accept-Language']).to.equal('nl-NL'); - }); - }); - - it('does not set "Accept-Language" header if ', async () => { - document.documentElement.lang = ''; - const req = new AjaxClass(); - req.get('data.json'); - await aTimeout(0); - expect(requests.length).to.equal(1); - expect(requests[0].requestHeaders['Accept-Language']).to.equal(undefined); - }); -}); diff --git a/packages/ajax/test/AjaxClass.test.js b/packages/ajax/test/AjaxClass.test.js deleted file mode 100644 index 109018fd70..0000000000 --- a/packages/ajax/test/AjaxClass.test.js +++ /dev/null @@ -1,312 +0,0 @@ -import { expect } from '@open-wc/testing'; -import sinon from 'sinon'; - -import { AjaxClass } from '../src/AjaxClass.js'; -import { ajax } from '../src/ajax.js'; - -describe('AjaxClass', () => { - /** @type {import('sinon').SinonFakeServer} */ - let server; - - /** - * @param {Object} [cfg] configuration for the AjaxClass instance - * @param {string} [cfg.jsonPrefix] prefixing the JSON string in this manner is used to help - * prevent JSON Hijacking. The prefix renders the string syntactically invalid as a script so - * that it cannot be hijacked. This prefix should be stripped before parsing the string as JSON. - * @param {string} [cfg.lang] language - * @param {boolean} [cfg.languageHeader] the Accept-Language request HTTP header advertises - * which languages the client is able to understand, and which locale variant is preferred. - * @param {boolean} [cfg.cancelable] if request can be canceled - * @param {boolean} [cfg.cancelPreviousOnNewRequest] prevents concurrent requests - */ - function getInstance(cfg) { - return new AjaxClass(cfg); - } - - beforeEach(() => { - server = sinon.fakeServer.create({ autoRespond: true }); - }); - - afterEach(() => { - server.restore(); - }); - - it('sets content type json if passed an object', async () => { - const myAjax = getInstance(); - server.respondWith('POST', /\/api\/foo/, [200, { 'Content-Type': 'application/json' }, '']); - await myAjax.post('/api/foo', { a: 1, b: 2 }); - expect(server.requests[0].requestHeaders['Content-Type']).to.include('application/json'); - }); - - describe('AjaxClass({ jsonPrefix: "%prefix%" })', () => { - it('adds new transformer to responseDataTransformers', () => { - const myAjaxWithout = getInstance({ jsonPrefix: '' }); - const myAjaxWith = getInstance({ jsonPrefix: 'prefix' }); - const lengthWithout = myAjaxWithout.responseDataTransformers.length; - const lengthWith = myAjaxWith.responseDataTransformers.length; - expect(lengthWith - lengthWithout).to.eql(1); - }); - - it('allows to customize anti-XSSI prefix', async () => { - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - 'for(;;);{"success":true}', - ]); - - const myAjax = getInstance({ jsonPrefix: 'for(;;);' }); - const response = await myAjax.get('data.json'); - expect(response.status).to.equal(200); - expect(response.data.success).to.equal(true); - }); - - it('works with non-JSON responses', async () => { - server.respondWith('GET', 'data.txt', [200, { 'Content-Type': 'text/plain' }, 'some text']); - - const myAjax = getInstance({ jsonPrefix: 'for(;;);' }); - const response = await myAjax.get('data.txt'); - expect(response.status).to.equal(200); - expect(response.data).to.equal('some text'); - }); - }); - - describe('AjaxClass({ cancelable: true })', () => { - it('adds new interceptor to requestInterceptors', () => { - const myAjaxWithout = getInstance(); - const myAjaxWith = getInstance({ cancelable: true }); - const lengthWithout = myAjaxWithout.requestInterceptors.length; - const lengthWith = myAjaxWith.requestInterceptors.length; - expect(lengthWith - lengthWithout).to.eql(1); - }); - - it('allows to cancel single running requests', async () => { - const myAjax = getInstance({ cancelable: true }); - - setTimeout(() => { - myAjax.cancel('is cancelled'); - }); - - try { - await myAjax.get('data.json'); - throw new Error('is not cancelled'); - } catch (error) { - expect(error.message).to.equal('is cancelled'); - } - }); - - it('allows to cancel multiple running requests', async () => { - const myAjax = getInstance({ cancelable: true }); - let cancelCount = 0; - - setTimeout(() => { - myAjax.cancel('is cancelled'); - }); - - const makeRequest = async () => { - try { - await myAjax.get('data.json'); - throw new Error('is not cancelled'); - } catch (error) { - expect(error.message).to.equal('is cancelled'); - cancelCount += 1; - } - }; - - await Promise.all([makeRequest(), makeRequest(), makeRequest()]); - - expect(cancelCount).to.equal(3); - }); - - it('does not cancel resolved requests', async () => { - const myAjax = getInstance({ cancelable: true }); - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - - try { - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - myAjax.cancel('is cancelled'); - } catch (error) { - throw new Error('is cancelled'); - } - }); - }); - - describe('AjaxClass({ cancelPreviousOnNewRequest: true })', () => { - it('adds new interceptor to requestInterceptors', () => { - const myAjaxWithout = getInstance(); - const myAjaxWith = getInstance({ cancelPreviousOnNewRequest: true }); - const lengthWithout = myAjaxWithout.requestInterceptors.length; - const lengthWith = myAjaxWith.requestInterceptors.length; - expect(lengthWith - lengthWithout).to.eql(1); - }); - - it('automatically cancels previous running request', async () => { - const myAjax = getInstance({ cancelPreviousOnNewRequest: true }); - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - - await Promise.all([ - (async () => { - try { - await myAjax.get('data.json'); - throw new Error('is resolved'); - } catch (error) { - expect(error.message).to.equal('Concurrent requests not allowed.'); - } - })(), - (async () => { - try { - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - ]); - }); - - it('automatically cancels multiple previous requests to the same endpoint', async () => { - const myAjax = getInstance({ cancelPreviousOnNewRequest: true }); - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - - const makeRequest = async () => { - try { - await myAjax.get('data.json'); - throw new Error('is resolved'); - } catch (error) { - expect(error.message).to.equal('Concurrent requests not allowed.'); - } - }; - - await Promise.all([ - makeRequest(), - makeRequest(), - makeRequest(), - (async () => { - try { - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - ]); - }); - - it('automatically cancels multiple previous requests to different endpoints', async () => { - const myAjax = getInstance({ cancelPreviousOnNewRequest: true }); - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - - const makeRequest = /** @param {string} url */ async url => { - try { - await myAjax.get(url); - throw new Error('is resolved'); - } catch (error) { - expect(error.message).to.equal('Concurrent requests not allowed.'); - } - }; - - await Promise.all([ - makeRequest('data1.json'), - makeRequest('data2.json'), - makeRequest('data3.json'), - (async () => { - try { - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - ]); - }); - - it('does not automatically cancel requests made via generic ajax', async () => { - const myAjax = getInstance({ cancelPreviousOnNewRequest: true }); - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - - await Promise.all([ - (async () => { - try { - await myAjax.get('data.json'); - throw new Error('is resolved'); - } catch (error) { - expect(error.message).to.equal('Concurrent requests not allowed.'); - } - })(), - (async () => { - try { - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - (async () => { - try { - const response = await ajax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - ]); - }); - - it('does not automatically cancel requests made via other instances', async () => { - const myAjax1 = getInstance({ cancelPreviousOnNewRequest: true }); - const myAjax2 = getInstance({ cancelPreviousOnNewRequest: true }); - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - - await Promise.all([ - (async () => { - try { - await myAjax1.get('data.json'); - throw new Error('is resolved'); - } catch (error) { - expect(error.message).to.equal('Concurrent requests not allowed.'); - } - })(), - (async () => { - try { - const response = await myAjax2.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - (async () => { - try { - const response = await myAjax1.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get' }); - } catch (error) { - throw new Error('is not resolved'); - } - })(), - ]); - }); - }); -}); diff --git a/packages/ajax/test/AjaxClass.transformers.test.js b/packages/ajax/test/AjaxClass.transformers.test.js deleted file mode 100644 index f8180ba1bb..0000000000 --- a/packages/ajax/test/AjaxClass.transformers.test.js +++ /dev/null @@ -1,118 +0,0 @@ -import { expect } from '@open-wc/testing'; -import sinon from 'sinon'; - -import { AjaxClass } from '../src/AjaxClass.js'; - -describe('AjaxClass transformers', () => { - /** @type {import('sinon').SinonFakeServer} */ - let server; - - /** - * @param {Object} [cfg] configuration for the AjaxClass instance - * @param {string} [cfg.jsonPrefix] prefixing the JSON string in this manner is used to help - * prevent JSON Hijacking. The prefix renders the string syntactically invalid as a script so - * that it cannot be hijacked. This prefix should be stripped before parsing the string as JSON. - * @param {string} [cfg.lang] language - * @param {boolean} [cfg.languageHeader] the Accept-Language request HTTP header advertises - * which languages the client is able to understand, and which locale variant is preferred. - * @param {boolean} [cfg.cancelable] if request can be canceled - * @param {boolean} [cfg.cancelPreviousOnNewRequest] prevents concurrent requests - */ - function getInstance(cfg) { - return new AjaxClass(cfg); - } - - beforeEach(() => { - server = sinon.fakeServer.create({ autoRespond: true }); - }); - - afterEach(() => { - server.restore(); - }); - - describe('use cases', () => { - it('can be added on a class for all instances', () => { - ['requestDataTransformers', 'responseDataTransformers'].forEach(type => { - const myInterceptor = () => {}; - class MyApi extends AjaxClass { - constructor() { - super(); - this[type] = [...this[type], myInterceptor]; - } - } - const ajaxWithout = getInstance(); - const ajaxWith = new MyApi(); - expect(ajaxWithout[type]).to.not.include(myInterceptor); - expect(ajaxWith[type]).to.include(myInterceptor); - }); - }); - - it('can be added per instance without changing the class', () => { - ['requestDataTransformers', 'responseDataTransformers'].forEach(type => { - const myInterceptor = () => {}; - const ajaxWithout = getInstance(); - const ajaxWith = getInstance(); - ajaxWith[type].push(myInterceptor); - expect(ajaxWithout[type]).to.not.include(myInterceptor); - expect(ajaxWith[type]).to.include(myInterceptor); - }); - }); - - it('can be removed after request', async () => { - await Promise.all( - ['requestDataTransformers', 'responseDataTransformers'].map(async type => { - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{}', - ]); - - const myTransformer = sinon.spy(foo => foo); - - const ajax = getInstance(); - - ajax[type].push(myTransformer); - await ajax.get('data.json'); - - ajax[type] = ajax[type].filter(/** @param {?} item */ item => item !== myTransformer); - await ajax.get('data.json'); - - expect(myTransformer.callCount).to.eql(1); - }), - ); - }); - }); - - describe('requestDataTransformers', () => { - it('allow to transform request data', async () => { - server.respondWith('POST', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "post" }', - ]); - const addBarTransformer = /** @param {?} data */ data => ({ ...data, bar: 'bar' }); - const myAjax = getInstance(); - myAjax.requestDataTransformers.push(addBarTransformer); - const response = await myAjax.post('data.json', { foo: 'foo' }); - expect(JSON.parse(response.config.data)).to.deep.equal({ - foo: 'foo', - bar: 'bar', - }); - }); - }); - - describe('responseDataTransformers', () => { - it('allow to transform response data', async () => { - server.respondWith('GET', 'data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "method": "get" }', - ]); - const addBarTransformer = /** @param {?} data */ data => ({ ...data, bar: 'bar' }); - const myAjax = getInstance(); - myAjax.responseDataTransformers.push(addBarTransformer); - const response = await myAjax.get('data.json'); - expect(response.data).to.deep.equal({ method: 'get', bar: 'bar' }); - }); - }); -}); diff --git a/packages/ajax/test/AjaxClient.test.js b/packages/ajax/test/AjaxClient.test.js new file mode 100644 index 0000000000..2db34abbd9 --- /dev/null +++ b/packages/ajax/test/AjaxClient.test.js @@ -0,0 +1,234 @@ +import { expect } from '@open-wc/testing'; +import { stub } from 'sinon'; +import { AjaxClient, AjaxClientFetchError } from '@lion/ajax'; + +describe('AjaxClient', () => { + /** @type {import('sinon').SinonStub} */ + let fetchStub; + /** @type {AjaxClient} */ + let ajax; + + beforeEach(() => { + fetchStub = stub(window, 'fetch'); + fetchStub.returns(Promise.resolve('mock response')); + ajax = new AjaxClient(); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + describe('request()', () => { + it('calls fetch with the given args, returning the result', async () => { + const response = await ajax.request('/foo', { method: 'POST' }); + + expect(fetchStub).to.have.been.calledOnce; + const request = fetchStub.getCall(0).args[0]; + expect(request.url).to.equal(`${window.location.origin}/foo`); + expect(request.method).to.equal('POST'); + expect(response).to.equal('mock response'); + }); + + it('throws on 4xx responses', async () => { + fetchStub.returns(Promise.resolve(new Response('', { status: 400 }))); + + let thrown = false; + try { + await ajax.request('/foo'); + } catch (e) { + expect(e).to.be.an.instanceOf(AjaxClientFetchError); + expect(e.request).to.be.an.instanceOf(Request); + expect(e.response).to.be.an.instanceOf(Response); + thrown = true; + } + expect(thrown).to.be.true; + }); + + it('throws on 5xx responses', async () => { + fetchStub.returns(Promise.resolve(new Response('', { status: 599 }))); + + let thrown = false; + try { + await ajax.request('/foo'); + } catch (e) { + expect(e).to.be.an.instanceOf(AjaxClientFetchError); + expect(e.request).to.be.an.instanceOf(Request); + expect(e.response).to.be.an.instanceOf(Response); + thrown = true; + } + expect(thrown).to.be.true; + }); + }); + + describe('requestJson', () => { + beforeEach(() => { + fetchStub.returns(Promise.resolve(new Response('{}'))); + }); + + it('sets json accept header', async () => { + await ajax.requestJson('/foo'); + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.get('accept')).to.equal('application/json'); + }); + + it('decodes response from json', async () => { + fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}'))); + const response = await ajax.requestJson('/foo'); + expect(response.body).to.eql({ a: 1, b: 2 }); + }); + + describe('given a request body', () => { + it('encodes the request body as json', async () => { + await ajax.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } }); + const request = fetchStub.getCall(0).args[0]; + expect(await request.text()).to.equal('{"a":1,"b":2}'); + }); + + it('sets json content-type header', async () => { + await ajax.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } }); + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.get('content-type')).to.equal('application/json'); + }); + }); + + describe('given a json prefix', () => { + it('strips json prefix from response before decoding', async () => { + const localAjax = new AjaxClient({ jsonPrefix: '//.,!' }); + fetchStub.returns(Promise.resolve(new Response('//.,!{"a":1,"b":2}'))); + const response = await localAjax.requestJson('/foo'); + expect(response.body).to.eql({ a: 1, b: 2 }); + }); + }); + }); + + describe('request and response interceptors', () => { + it('addRequestInterceptor() adds a function which intercepts the request', async () => { + ajax.addRequestInterceptor(async r => { + return new Request(`${r.url}/intercepted-1`); + }); + ajax.addRequestInterceptor(async r => new Request(`${r.url}/intercepted-2`)); + + await ajax.request('/foo', { method: 'POST' }); + + const request = fetchStub.getCall(0).args[0]; + expect(request.url).to.equal(`${window.location.origin}/foo/intercepted-1/intercepted-2`); + }); + + it('addResponseInterceptor() adds a function which intercepts the response', async () => { + // @ts-expect-error we're mocking the response as a simple promise which returns a string + ajax.addResponseInterceptor(r => `${r} intercepted-1`); + // @ts-expect-error we're mocking the response as a simple promise which returns a string + ajax.addResponseInterceptor(r => `${r} intercepted-2`); + + const response = await ajax.request('/foo', { method: 'POST' }); + expect(response).to.equal('mock response intercepted-1 intercepted-2'); + }); + + it('removeRequestInterceptor() removes a request interceptor', async () => { + const interceptor = /** @param {Request} r */ async r => + new Request(`${r.url}/intercepted-1`); + ajax.addRequestInterceptor(interceptor); + ajax.removeRequestInterceptor(interceptor); + + await ajax.request('/foo', { method: 'POST' }); + + const request = fetchStub.getCall(0).args[0]; + expect(request.url).to.equal(`${window.location.origin}/foo`); + }); + + it('removeResponseInterceptor() removes a request interceptor', async () => { + const interceptor = /** @param {Response} r */ r => `${r} intercepted-1`; + // @ts-expect-error we're mocking the response as a simple promise which returns a string + ajax.addResponseInterceptor(interceptor); + // @ts-expect-error we're mocking the response as a simple promise which returns a string + ajax.removeResponseInterceptor(interceptor); + + const response = await ajax.request('/foo', { method: 'POST' }); + expect(response).to.equal('mock response'); + }); + }); + + describe('accept-language header', () => { + it('is set by default based on localize.locale', async () => { + await ajax.request('/foo'); + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.get('accept-language')).to.equal('en'); + }); + + it('can be disabled', async () => { + const customAjax = new AjaxClient({ addAcceptLanguage: false }); + await customAjax.request('/foo'); + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.has('accept-language')).to.be.false; + }); + }); + + describe('XSRF token', () => { + /** @type {import('sinon').SinonStub} */ + let cookieStub; + beforeEach(() => { + cookieStub = stub(document, 'cookie'); + cookieStub.get(() => 'foo=bar;XSRF-TOKEN=1234; CSRF-TOKEN=5678;lorem=ipsum;'); + }); + + afterEach(() => { + cookieStub.restore(); + }); + + it('XSRF token header is set based on cookie', async () => { + await ajax.request('/foo'); + + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234'); + }); + + it('XSRF behavior can be disabled', async () => { + const customAjax = new AjaxClient({ xsrfCookieName: null, xsrfHeaderName: null }); + await customAjax.request('/foo'); + await ajax.request('/foo'); + + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.has('X-XSRF-TOKEN')).to.be.false; + }); + + it('XSRF token header and cookie can be customized', async () => { + const customAjax = new AjaxClient({ + xsrfCookieName: 'CSRF-TOKEN', + xsrfHeaderName: 'X-CSRF-TOKEN', + }); + await customAjax.request('/foo'); + + const request = fetchStub.getCall(0).args[0]; + expect(request.headers.get('X-CSRF-TOKEN')).to.equal('5678'); + }); + }); + + describe('Abort', () => { + it('support aborting requests with AbortController', async () => { + fetchStub.restore(); + let err; + const controller = new AbortController(); + const { signal } = controller; + // Have to do a "real" request to be able to abort it and verify that this throws + const req = ajax.request(new URL('./foo.json', import.meta.url).pathname, { + method: 'GET', + signal, + }); + controller.abort(); + + try { + await req; + } catch (e) { + err = e; + } + + const errors = [ + "Failed to execute 'fetch' on 'Window': The user aborted a request.", // chromium + 'The operation was aborted. ', // firefox + 'Request signal is aborted', // webkit + ]; + + expect(errors.includes(err.message)).to.be.true; + }); + }); +}); diff --git a/packages/ajax/test/ajax.test.js b/packages/ajax/test/ajax.test.js index b3fb262c3e..289a886ef0 100644 --- a/packages/ajax/test/ajax.test.js +++ b/packages/ajax/test/ajax.test.js @@ -1,125 +1,14 @@ import { expect } from '@open-wc/testing'; -import sinon from 'sinon'; - -import { ajax } from '../src/ajax.js'; +import { ajax, setAjax, AjaxClient } from '@lion/ajax'; describe('ajax', () => { - /** @type {import('sinon').SinonFakeServer} */ - let server; - - beforeEach(() => { - server = sinon.fakeServer.create({ autoRespond: true }); - }); - - afterEach(() => { - server.restore(); - }); - - it('interprets Content-Type of the response by default', async () => { - server.respondWith('GET', '/path/to/data/', [ - 200, - { 'Content-Type': 'application/json' }, - '{ "json": "yes" }', - ]); - - const response = await ajax.get('/path/to/data/'); - expect(response.status).to.equal(200); - expect(response.data).to.deep.equal({ json: 'yes' }); - }); - - it('supports signature (url[, config]) for get(), request(), delete(), head()', async () => { - server.respondWith('data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{"success": true}', - ]); - const makeRequest = /** @param {string} method */ async method => { - const response = await ajax[method]('data.json', { foo: 'bar' }); - expect(response.status).to.equal(200); - expect(response.data).to.deep.equal({ success: true }); - }; - await Promise.all(['get', 'request', 'delete', 'head'].map(m => makeRequest(m))); - }); - - it('supports signature (url[, data[, config]]) for post(), put(), patch()', async () => { - server.respondWith('data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{"success": true}', - ]); - const makeRequest = /** @param {string} method */ async method => { - const response = await ajax[method]('data.json', { data: 'foobar' }, { foo: 'bar' }); - expect(response.status).to.equal(200); - expect(response.data).to.deep.equal({ success: true }); - }; - await Promise.all(['post', 'put', 'patch'].map(m => makeRequest(m))); - }); - - it('supports GET, POST, PUT, DELETE, REQUEST, PATCH and HEAD methods with XSRF token', async () => { - document.cookie = 'XSRF-TOKEN=test; '; - server.respondWith('data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{"success": true}', - ]); - - const makeRequest = /** @param {string} method */ async method => { - const response = await ajax[method]('data.json'); - expect(response.config.headers['X-XSRF-TOKEN']).to.equal('test'); - expect(response.status).to.equal(200); - expect(response.data).to.deep.equal({ success: true }); - }; - - await Promise.all( - ['get', 'post', 'put', 'delete', 'request', 'patch', 'head'].map(m => makeRequest(m)), - ); + it('exports an instance of AjaxClient', () => { + expect(ajax).to.be.an.instanceOf(AjaxClient); }); - it('supports GET, POST, PUT, DELETE, REQUEST, PATCH and HEAD methods without XSRF token', async () => { - document.cookie = 'XSRF-TOKEN=; '; - server.respondWith('data.json', [ - 200, - { 'Content-Type': 'application/json' }, - '{"success": true}', - ]); - - const makeRequest = /** @param {string} method */ async method => { - const response = await ajax[method]('data.json'); - expect(response.config.headers['X-XSRF-TOKEN']).to.equal(undefined); - expect(response.status).to.equal(200); - expect(response.data).to.deep.equal({ success: true }); - }; - - await Promise.all( - ['get', 'post', 'put', 'delete', 'request', 'patch', 'head'].map(m => makeRequest(m)), - ); - }); - - it('supports empty responses', async () => { - server.respondWith('GET', 'data.json', [200, { 'Content-Type': 'application/json' }, '']); - - const response = await ajax.get('data.json'); - expect(response.status).to.equal(200); - expect(response.data).to.equal(''); - }); - - it('supports error responses', async () => { - server.respondWith('GET', 'data.json', [500, { 'Content-Type': 'application/json' }, '']); - - try { - await ajax.get('data.json'); - throw new Error('error is not handled'); - } catch (error) { - expect(error).to.be.an.instanceof(Error); - expect(error.response.status).to.equal(500); - } - }); - - it('supports non-JSON responses', async () => { - server.respondWith('GET', 'data.txt', [200, { 'Content-Type': 'text/plain' }, 'some text']); - - const response = await ajax.get('data.txt'); - expect(response.status).to.equal(200); - expect(response.data).to.equal('some text'); + it('can replace ajax with another instance', () => { + const newAjax = new AjaxClient(); + setAjax(newAjax); + expect(ajax).to.equal(newAjax); }); }); diff --git a/packages/ajax/test/foo.json b/packages/ajax/test/foo.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/ajax/test/foo.json @@ -0,0 +1 @@ +{} diff --git a/packages/ajax/test/interceptors.test.js b/packages/ajax/test/interceptors.test.js new file mode 100644 index 0000000000..b0350d0c27 --- /dev/null +++ b/packages/ajax/test/interceptors.test.js @@ -0,0 +1,62 @@ +import { expect } from '@open-wc/testing'; +import { + createXSRFRequestInterceptor, + getCookie, + acceptLanguageRequestInterceptor, +} from '@lion/ajax'; + +describe('interceptors', () => { + describe('getCookie()', () => { + it('returns the cookie value', () => { + expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar'); + }); + + it('returns the cookie value when there are multiple cookies', () => { + expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar'); + }); + + it('returns null when the cookie cannot be found', () => { + expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null); + }); + + it('decodes the cookie vaue', () => { + expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal( + '/foo/ bar "', + ); + }); + }); + + describe('acceptLanguageRequestInterceptor()', () => { + it('adds the locale as accept-language header', () => { + const request = new Request('/foo/'); + acceptLanguageRequestInterceptor(request); + expect(request.headers.get('accept-language')).to.equal('en'); + }); + + it('does not change an existing accept-language header', () => { + const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } }); + acceptLanguageRequestInterceptor(request); + expect(request.headers.get('accept-language')).to.equal('my-accept'); + }); + }); + + describe('createXSRFRequestInterceptor()', () => { + it('adds the xsrf token header to the request', () => { + const interceptor = createXSRFRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', { + cookie: 'XSRF-TOKEN=foo', + }); + const request = new Request('/foo/'); + interceptor(request); + expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo'); + }); + + it('does not set anything if the cookie is not there', () => { + const interceptor = createXSRFRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', { + cookie: 'XXSRF-TOKEN=foo', + }); + const request = new Request('/foo/'); + interceptor(request); + expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null); + }); + }); +}); diff --git a/packages/ajax/types/ajaxClientTypes.d.ts b/packages/ajax/types/ajaxClientTypes.d.ts new file mode 100644 index 0000000000..94d591a550 --- /dev/null +++ b/packages/ajax/types/ajaxClientTypes.d.ts @@ -0,0 +1,10 @@ +/** + * We have a method requestJson that encodes JS Object to + * a string automatically for `body` property. + * Sadly, Typescript doesn't allow us to extend RequestInit + * and override body prop because it is incompatible, so we + * omit it first from the base RequestInit. + */ +export interface LionRequestInit extends Omit { + body?: BodyInit | null | Object; +} diff --git a/yarn.lock b/yarn.lock index d2c11a724d..80788ca18d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1021,11 +1021,6 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-3.1.0.tgz#8ff71d51053cd5ee4981e5a501d80a536244f7fd" integrity sha512-GcIY79elgB+azP74j8vqkiXz8xLFfIzbQJdlwOPisgbKT00tviJQuEghOXSMVxJ00HoYJbGswr4kcllUc4xCcg== -"@bundled-es-modules/axios@0.18.1": - version "0.18.1" - resolved "https://registry.yarnpkg.com/@bundled-es-modules/axios/-/axios-0.18.1.tgz#8beedbc92e9b0ed7df7c6cbdc6dfce84d306d80b" - integrity sha512-7c389uGe0dmfdedi9PQ3Om4vKg1HFzm/IntaqZ4FbXOo+gNiiPIM4He8MIkuRpgqUitbm1km0jOQ8p+tSpUp4Q== - "@bundled-es-modules/fetch-mock@^6.5.2": version "6.5.2" resolved "https://registry.yarnpkg.com/@bundled-es-modules/fetch-mock/-/fetch-mock-6.5.2.tgz#f68d78dba49ffcb5b58bede5974c8a9dd035a6fb"