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"