fix(ajax): rename to ajax, async interceptors
This commit is contained in:
parent
c0659a8d5d
commit
4452d06d44
28 changed files with 584 additions and 7202 deletions
5
.changeset/blue-shoes-shave.md
Normal file
5
.changeset/blue-shoes-shave.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,198 +1,99 @@
|
||||||
|
[//]: # 'AUTO INSERT HEADER PREPUBLISH'
|
||||||
|
|
||||||
# Ajax
|
# Ajax
|
||||||
|
|
||||||
`ajax` is the global manager for handling all ajax requests.
|
`ajax` is a small wrapper around `fetch` which:
|
||||||
It is a promise based system for fetching data, based on [axios](https://github.com/axios/axios)
|
|
||||||
|
|
||||||
```js script
|
- Allows globally registering request and response interceptors
|
||||||
import { html } from '@lion/core';
|
- Throws on 4xx and 5xx status codes
|
||||||
import { ajax } from './src/ajax.js';
|
- Prevents network request if a request interceptor returns a response
|
||||||
import { AjaxClass } from './src/AjaxClass.js';
|
- 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
|
||||||
export default {
|
- Adds XSRF header to request if the cookie is present
|
||||||
title: 'Others/Ajax',
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
npm i --save @lion/ajax
|
npm i --save @lion/ajax
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
### Relation to fetch
|
||||||
import { ajax, AjaxClass } from '@lion/ajax';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
```js
|
||||||
import { ajax } from '@lion/ajax';
|
import { ajax } from '@lion/ajax';
|
||||||
|
|
||||||
ajax.get('data.json').then(response => console.log(response));
|
const response = await ajax.request('/api/users');
|
||||||
|
const users = await response.json();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Performing requests
|
#### POST request
|
||||||
|
|
||||||
Performing a `GET` request:
|
|
||||||
|
|
||||||
```js preview-story
|
|
||||||
export const performingGetRequests = () => html`
|
|
||||||
<button
|
|
||||||
@click=${() => {
|
|
||||||
ajax
|
|
||||||
.get('./packages/ajax/docs/assets/data.json')
|
|
||||||
.then(response => {
|
|
||||||
console.log(response.data);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Execute Request to Action Logger
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
```
|
|
||||||
|
|
||||||
To post data to the server, pass the data as the second argument in the `POST` request:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const body = {
|
import { ajax } from '@lion/ajax';
|
||||||
ant: {
|
|
||||||
type: 'insect',
|
const response = await ajax.request('/api/users', {
|
||||||
limbs: 6,
|
method: 'POST',
|
||||||
},
|
body: JSON.stringify({ username: 'steve' }),
|
||||||
};
|
|
||||||
ajax
|
|
||||||
.post('zooApi/animals/addAnimal', body)
|
|
||||||
.then(response => {
|
|
||||||
console.log(`POST successful: ${response.status} ${response.statusText}`);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
});
|
||||||
|
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.
|
#### GET JSON request
|
||||||
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.
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const myAjax = new AjaxClass({ jsonPrefix: ")]}'," });
|
import { ajax } from '@lion/ajax';
|
||||||
myAjax
|
|
||||||
.get('./packages/ajax/docs/assets/data.json')
|
const { response, body } = await ajax.requestJson('/api/users');
|
||||||
.then(response => {
|
```
|
||||||
console.log(response.data);
|
|
||||||
})
|
#### POST JSON request
|
||||||
.catch(error => {
|
|
||||||
console.log(error);
|
```js
|
||||||
|
import { ajax } from '@lion/ajax';
|
||||||
|
|
||||||
|
const { response, body } = await ajax.requestJson('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username: 'steve' },
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Additional headers
|
### Error handling
|
||||||
|
|
||||||
Add additional headers to the requests with the `headers` option.
|
Different from fetch, `ajax` throws when the server returns a 4xx or 5xx, returning the request and response:
|
||||||
|
|
||||||
```js preview-story
|
```js
|
||||||
export const additionalHeaders = () => html`
|
import { ajax } from '@lion/ajax';
|
||||||
<button
|
|
||||||
@click=${() => {
|
try {
|
||||||
const myAjax = new AjaxClass({ headers: { 'MY-HEADER': 'SOME-HEADER-VALUE' } });
|
const users = await ajax.requestJson('/api/users');
|
||||||
myAjax
|
} catch (error) {
|
||||||
.get('./packages/ajax/docs/assets/data.json')
|
if (error.response) {
|
||||||
.then(response => {
|
if (error.response.status === 400) {
|
||||||
console.log(response);
|
// handle a specific status code, for example 400 bad request
|
||||||
})
|
} else {
|
||||||
.catch(error => {
|
console.error(error);
|
||||||
console.log(error);
|
}
|
||||||
});
|
} else {
|
||||||
}}
|
// an error happened before receiving a response, ex. an incorrect request or network error
|
||||||
>
|
console.error(error);
|
||||||
Execute Request to Action Logger
|
}
|
||||||
</button>
|
}
|
||||||
`;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
## Fetch Polyfill
|
||||||
|
|
||||||
### Cancelable Request
|
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.
|
||||||
|
|
||||||
It is possible to make an Ajax request cancelable, and then call `cancel()` to make the request provide a custom error once fired.
|
[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)
|
||||||
|
|
||||||
```js preview-story
|
|
||||||
export const cancelableRequests = () => html`
|
|
||||||
<button
|
|
||||||
@click=${() => {
|
|
||||||
const myAjax = new AjaxClass({ cancelable: true });
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
myAjax.cancel('too slow');
|
|
||||||
});
|
|
||||||
myAjax
|
|
||||||
.get('./packages/ajax/docs/assets/data.json')
|
|
||||||
.then(response => {
|
|
||||||
console.log(response.data);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Execute Request to Action Logger
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cancel concurrent requests
|
|
||||||
|
|
||||||
You can cancel concurrent requests with the `cancelPreviousOnNewRequest` option.
|
|
||||||
|
|
||||||
```js preview-story
|
|
||||||
export const cancelConcurrentRequests = () => html`
|
|
||||||
<button
|
|
||||||
@click=${() => {
|
|
||||||
const myAjax = new AjaxClass({ cancelPreviousOnNewRequest: true });
|
|
||||||
myAjax
|
|
||||||
.get('./packages/ajax/docs/assets/data.json')
|
|
||||||
.then(response => {
|
|
||||||
console.log(response.data);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.log(error.message);
|
|
||||||
});
|
|
||||||
myAjax
|
|
||||||
.get('./packages/ajax/docs/assets/data.json')
|
|
||||||
.then(response => {
|
|
||||||
console.log(response.data);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.log(error.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Execute Both Requests to Action Logger
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Considerations
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Future plans
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"animals": {
|
|
||||||
"cow": {
|
|
||||||
"type": "mammal",
|
|
||||||
"limbs": 4
|
|
||||||
},
|
|
||||||
"frog": {
|
|
||||||
"type": "amphibian",
|
|
||||||
"limbs": 4
|
|
||||||
},
|
|
||||||
"snake": {
|
|
||||||
"type": "reptile",
|
|
||||||
"limbs": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
export { ajax, setAjax } from './src/ajax.js';
|
export { ajax, setAjax } from './src/ajax.js';
|
||||||
|
export { AjaxClient } from './src/AjaxClient.js';
|
||||||
export { AjaxClass } from './src/AjaxClass.js';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
cancelInterceptorFactory,
|
acceptLanguageRequestInterceptor,
|
||||||
cancelPreviousOnNewRequestInterceptorFactory,
|
createXSRFRequestInterceptor,
|
||||||
addAcceptLanguageHeaderInterceptorFactory,
|
|
||||||
} from './src/interceptors.js';
|
} from './src/interceptors.js';
|
||||||
|
|
||||||
export { jsonPrefixTransformerFactory } from './src/transformers.js';
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@lion/ajax",
|
"name": "@lion/ajax",
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"description": "Thin wrapper around axios to allow for custom interceptors",
|
"description": "Thin wrapper around fetch.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "ing-bank",
|
"author": "ing-bank",
|
||||||
"homepage": "https://github.com/ing-bank/lion/",
|
"homepage": "https://github.com/ing-bank/lion/",
|
||||||
|
|
@ -29,14 +29,13 @@
|
||||||
"prepublishOnly": "../../scripts/npm-prepublish.js",
|
"prepublishOnly": "../../scripts/npm-prepublish.js",
|
||||||
"test": "cd ../../ && npm run test:browser -- --group ajax"
|
"test": "cd ../../ && npm run test:browser -- --group ajax"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bundled-es-modules/axios": "0.18.1",
|
"@lion/localize": "0.15.5"
|
||||||
"@lion/core": "0.13.8",
|
|
||||||
"singleton-manager": "1.2.1"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ajax",
|
"ajax",
|
||||||
|
"fetch",
|
||||||
|
"http",
|
||||||
"lion",
|
"lion",
|
||||||
"web-components"
|
"web-components"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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.<RequestInterceptor>} */
|
|
||||||
this.requestInterceptors = [];
|
|
||||||
/** @type {Array.<RequestInterceptor>} */
|
|
||||||
this.requestErrorInterceptors = [];
|
|
||||||
/** @type {Array.<RequestInterceptor>} */
|
|
||||||
this.responseErrorInterceptors = [];
|
|
||||||
/** @type {Array.<ResponseInterceptor>} */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
174
packages/ajax/src/AjaxClient.js
Normal file
174
packages/ajax/src/AjaxClient.js
Normal file
|
|
@ -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<Request | Response>} RequestInterceptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepts a Response before returning. Must return an instance of Response.
|
||||||
|
* @typedef {(response: Response) => Promise<Response>} 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<Response>}
|
||||||
|
*/
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
export class HttpClientFetchError extends Error {
|
export class AjaxClientFetchError extends Error {
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
* @param {Response} response
|
||||||
|
*/
|
||||||
constructor(request, response) {
|
constructor(request, response) {
|
||||||
super(`Fetch request to ${request.url} failed.`);
|
super(`Fetch request to ${request.url} failed.`);
|
||||||
|
|
||||||
this.request = request;
|
this.request = request;
|
||||||
this.response = response;
|
this.response = response;
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import { singletonManager } from 'singleton-manager';
|
import { AjaxClient } from './AjaxClient.js';
|
||||||
import { AjaxClass } from './AjaxClass.js';
|
|
||||||
|
|
||||||
/**
|
export let ajax = new AjaxClient(); // eslint-disable-line import/no-mutable-exports
|
||||||
*
|
|
||||||
*/
|
|
||||||
export let ajax = singletonManager.get('@lion/ajax::ajax::0.3.x') || new AjaxClass(); // eslint-disable-line import/no-mutable-exports
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* setAjax allows the Application Developer to override the globally used instance of {@link:ajax}.
|
* 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
|
* 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
|
* (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.)
|
* 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) {
|
export function setAjax(newAjax) {
|
||||||
ajax = newAjax;
|
ajax = newAjax;
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,50 @@
|
||||||
// @ts-ignore no types for bundled-es-modules/axios
|
import { localize } from '@lion/localize';
|
||||||
import { axios } from '@bundled-es-modules/axios';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} [lang]
|
* @typedef {import('./AjaxClient').RequestInterceptor} RequestInterceptor
|
||||||
* @return {(config: {[key:string]: ?}) => {[key:string]: ?}}
|
|
||||||
*/
|
*/
|
||||||
export function addAcceptLanguageHeaderInterceptorFactory(lang) {
|
|
||||||
return /** @param {{[key:string]: ?}} config */ config => {
|
/**
|
||||||
const result = config;
|
* @param {string} name the cookie name
|
||||||
if (typeof lang === 'string' && lang !== '') {
|
* @param {Document | { cookie: string }} _document overwriteable for testing
|
||||||
if (typeof result.headers !== 'object') {
|
* @returns {string | null}
|
||||||
result.headers = {};
|
*/
|
||||||
}
|
export function getCookie(name, _document = document) {
|
||||||
const withLang = { headers: { 'Accept-Language': lang, ...result.headers } };
|
const match = _document.cookie.match(new RegExp(`(^|;\\s*)(${name})=([^;]*)`));
|
||||||
return { ...result, ...withLang };
|
return match ? decodeURIComponent(match[3]) : null;
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('./AjaxClass').AjaxClass} ajaxInstance
|
* Transforms a request, adding an accept-language header with the current application's locale
|
||||||
* @return {(config: {[key:string]: ?}) => {[key:string]: ?}}
|
* if it has not already been set.
|
||||||
|
* @type {RequestInterceptor}
|
||||||
*/
|
*/
|
||||||
export function cancelInterceptorFactory(ajaxInstance) {
|
export async function acceptLanguageRequestInterceptor(request) {
|
||||||
/** @type {unknown[]} */
|
if (!request.headers.has('accept-language')) {
|
||||||
const cancelSources = [];
|
request.headers.set('accept-language', localize.locale);
|
||||||
return /** @param {{[key:string]: ?}} config */ config => {
|
}
|
||||||
const source = axios.CancelToken.source();
|
return request;
|
||||||
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 };
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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() {
|
export function createXSRFRequestInterceptor(cookieName, headerName, _document = document) {
|
||||||
// @ts-ignore axios is untyped so we don't know the type for the source
|
/**
|
||||||
let prevCancelSource;
|
* @type {RequestInterceptor}
|
||||||
return /** @param {{[key:string]: ?}} config */ config => {
|
*/
|
||||||
// @ts-ignore axios is untyped so we don't know the type for the source
|
async function xsrfRequestInterceptor(request) {
|
||||||
if (prevCancelSource) {
|
const xsrfToken = getCookie(cookieName, _document);
|
||||||
// @ts-ignore
|
if (xsrfToken) {
|
||||||
prevCancelSource.cancel('Concurrent requests not allowed.');
|
request.headers.set(headerName, xsrfToken);
|
||||||
}
|
}
|
||||||
const source = axios.CancelToken.source();
|
return request;
|
||||||
prevCancelSource = source;
|
}
|
||||||
return { ...config, cancelToken: source.token };
|
|
||||||
};
|
return xsrfRequestInterceptor;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 <html lang="en-GB">', 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 <html lang="en-GB">', 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 <html lang="nl-NL">', 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 <html lang="nl-NL">', 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 <html lang="">', 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { expect } from '@open-wc/testing';
|
import { expect } from '@open-wc/testing';
|
||||||
import { stub } from 'sinon';
|
import { stub } from 'sinon';
|
||||||
import { localize } from '@lion/localize';
|
import { localize } from '@lion/localize';
|
||||||
import { HttpClient } from '../src/HttpClient.js';
|
import { AjaxClient } from '../src/AjaxClient.js';
|
||||||
import { HttpClientFetchError } from '../src/HttpClientFetchError.js';
|
import { AjaxClientFetchError } from '../src/AjaxClientFetchError.js';
|
||||||
|
|
||||||
describe('HttpClient', () => {
|
describe('AjaxClient', () => {
|
||||||
/** @type {import('sinon').SinonStub} */
|
/** @type {import('sinon').SinonStub} */
|
||||||
let fetchStub;
|
let fetchStub;
|
||||||
/** @type {HttpClient} */
|
/** @type {AjaxClient} */
|
||||||
let http;
|
let ajax;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchStub = stub(window, 'fetch');
|
fetchStub = stub(window, 'fetch');
|
||||||
fetchStub.returns(Promise.resolve('mock response'));
|
fetchStub.returns(Promise.resolve('mock response'));
|
||||||
http = new HttpClient();
|
ajax = new AjaxClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -22,7 +22,7 @@ describe('HttpClient', () => {
|
||||||
|
|
||||||
describe('request()', () => {
|
describe('request()', () => {
|
||||||
it('calls fetch with the given args, returning the result', async () => {
|
it('calls fetch with the given args, returning the result', async () => {
|
||||||
const response = await http.request('/foo', { method: 'POST' });
|
const response = await ajax.request('/foo', { method: 'POST' });
|
||||||
|
|
||||||
expect(fetchStub).to.have.been.calledOnce;
|
expect(fetchStub).to.have.been.calledOnce;
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
|
|
@ -36,9 +36,9 @@ describe('HttpClient', () => {
|
||||||
|
|
||||||
let thrown = false;
|
let thrown = false;
|
||||||
try {
|
try {
|
||||||
await http.request('/foo');
|
await ajax.request('/foo');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).to.be.an.instanceOf(HttpClientFetchError);
|
expect(e).to.be.an.instanceOf(AjaxClientFetchError);
|
||||||
expect(e.request).to.be.an.instanceOf(Request);
|
expect(e.request).to.be.an.instanceOf(Request);
|
||||||
expect(e.response).to.be.an.instanceOf(Response);
|
expect(e.response).to.be.an.instanceOf(Response);
|
||||||
thrown = true;
|
thrown = true;
|
||||||
|
|
@ -51,9 +51,9 @@ describe('HttpClient', () => {
|
||||||
|
|
||||||
let thrown = false;
|
let thrown = false;
|
||||||
try {
|
try {
|
||||||
await http.request('/foo');
|
await ajax.request('/foo');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).to.be.an.instanceOf(HttpClientFetchError);
|
expect(e).to.be.an.instanceOf(AjaxClientFetchError);
|
||||||
expect(e.request).to.be.an.instanceOf(Request);
|
expect(e.request).to.be.an.instanceOf(Request);
|
||||||
expect(e.response).to.be.an.instanceOf(Response);
|
expect(e.response).to.be.an.instanceOf(Response);
|
||||||
thrown = true;
|
thrown = true;
|
||||||
|
|
@ -68,26 +68,26 @@ describe('HttpClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets json accept header', async () => {
|
it('sets json accept header', async () => {
|
||||||
await http.requestJson('/foo');
|
await ajax.requestJson('/foo');
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('accept')).to.equal('application/json');
|
expect(request.headers.get('accept')).to.equal('application/json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decodes response from json', async () => {
|
it('decodes response from json', async () => {
|
||||||
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}')));
|
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}')));
|
||||||
const response = await http.requestJson('/foo');
|
const response = await ajax.requestJson('/foo');
|
||||||
expect(response.body).to.eql({ a: 1, b: 2 });
|
expect(response.body).to.eql({ a: 1, b: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('given a request body', () => {
|
describe('given a request body', () => {
|
||||||
it('encodes the request body as json', async () => {
|
it('encodes the request body as json', async () => {
|
||||||
await http.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
await ajax.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(await request.text()).to.equal('{"a":1,"b":2}');
|
expect(await request.text()).to.equal('{"a":1,"b":2}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets json content-type header', async () => {
|
it('sets json content-type header', async () => {
|
||||||
await http.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
await ajax.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('content-type')).to.equal('application/json');
|
expect(request.headers.get('content-type')).to.equal('application/json');
|
||||||
});
|
});
|
||||||
|
|
@ -95,70 +95,78 @@ describe('HttpClient', () => {
|
||||||
|
|
||||||
describe('given a json prefix', () => {
|
describe('given a json prefix', () => {
|
||||||
it('strips json prefix from response before decoding', async () => {
|
it('strips json prefix from response before decoding', async () => {
|
||||||
const localHttp = new HttpClient({ jsonPrefix: '//.,!' });
|
const localAjax = new AjaxClient({ jsonPrefix: '//.,!' });
|
||||||
fetchStub.returns(Promise.resolve(new Response('//.,!{"a":1,"b":2}')));
|
fetchStub.returns(Promise.resolve(new Response('//.,!{"a":1,"b":2}')));
|
||||||
const response = await localHttp.requestJson('/foo');
|
const response = await localAjax.requestJson('/foo');
|
||||||
expect(response.body).to.eql({ a: 1, b: 2 });
|
expect(response.body).to.eql({ a: 1, b: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('request and response transformers', () => {
|
describe('request and response interceptors', () => {
|
||||||
it('addRequestTransformer() adds a function which transforms the request', async () => {
|
it('addRequestInterceptor() adds a function which intercepts the request', async () => {
|
||||||
http.addRequestTransformer(r => new Request(`${r.url}/transformed-1`));
|
ajax.addRequestInterceptor(async r => {
|
||||||
http.addRequestTransformer(r => new Request(`${r.url}/transformed-2`));
|
return new Request(`${r.url}/intercepted-1`);
|
||||||
|
});
|
||||||
|
ajax.addRequestInterceptor(async r => new Request(`${r.url}/intercepted-2`));
|
||||||
|
|
||||||
await http.request('/foo', { method: 'POST' });
|
await ajax.request('/foo', { method: 'POST' });
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.url).to.equal(`${window.location.origin}/foo/transformed-1/transformed-2`);
|
expect(request.url).to.equal(`${window.location.origin}/foo/intercepted-1/intercepted-2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('addResponseTransformer() adds a function which transforms the response', async () => {
|
it('addResponseInterceptor() adds a function which intercepts the response', async () => {
|
||||||
http.addResponseTransformer(r => `${r} transformed-1`);
|
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
||||||
http.addResponseTransformer(r => `${r} transformed-2`);
|
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 http.request('/foo', { method: 'POST' });
|
const response = await ajax.request('/foo', { method: 'POST' });
|
||||||
expect(response).to.equal('mock response transformed-1 transformed-2');
|
expect(response).to.equal('mock response intercepted-1 intercepted-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removeRequestTransformer() removes a request transformer', async () => {
|
it('removeRequestInterceptor() removes a request interceptor', async () => {
|
||||||
const transformer = r => new Request(`${r.url}/transformed-1`);
|
const interceptor = /** @param {Request} r */ async r =>
|
||||||
http.addRequestTransformer(transformer);
|
new Request(`${r.url}/intercepted-1`);
|
||||||
http.removeRequestTransformer(transformer);
|
ajax.addRequestInterceptor(interceptor);
|
||||||
|
ajax.removeRequestInterceptor(interceptor);
|
||||||
|
|
||||||
await http.request('/foo', { method: 'POST' });
|
await ajax.request('/foo', { method: 'POST' });
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.url).to.equal(`${window.location.origin}/foo`);
|
expect(request.url).to.equal(`${window.location.origin}/foo`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removeResponseTransformer() removes a request transformer', async () => {
|
it('removeResponseInterceptor() removes a request interceptor', async () => {
|
||||||
const transformer = r => `${r} transformed-1`;
|
const interceptor = /** @param {Response} r */ r => `${r} intercepted-1`;
|
||||||
http.addResponseTransformer(transformer);
|
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
||||||
http.removeResponseTransformer(transformer);
|
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 http.request('/foo', { method: 'POST' });
|
const response = await ajax.request('/foo', { method: 'POST' });
|
||||||
expect(response).to.equal('mock response');
|
expect(response).to.equal('mock response');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('accept-language header', () => {
|
describe('accept-language header', () => {
|
||||||
it('is set by default based on localize.locale', async () => {
|
it('is set by default based on localize.locale', async () => {
|
||||||
await http.request('/foo');
|
await ajax.request('/foo');
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('accept-language')).to.equal(localize.locale);
|
expect(request.headers.get('accept-language')).to.equal(localize.locale);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can be disabled', async () => {
|
it('can be disabled', async () => {
|
||||||
const customHttp = new HttpClient({ addAcceptLanguage: false });
|
const customAjax = new AjaxClient({ addAcceptLanguage: false });
|
||||||
await customHttp.request('/foo');
|
await customAjax.request('/foo');
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.has('accept-language')).to.be.false;
|
expect(request.headers.has('accept-language')).to.be.false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('XSRF token', () => {
|
describe('XSRF token', () => {
|
||||||
|
/** @type {import('sinon').SinonStub} */
|
||||||
let cookieStub;
|
let cookieStub;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cookieStub = stub(document, 'cookie');
|
cookieStub = stub(document, 'cookie');
|
||||||
|
|
@ -170,30 +178,59 @@ describe('HttpClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('XSRF token header is set based on cookie', async () => {
|
it('XSRF token header is set based on cookie', async () => {
|
||||||
await http.request('/foo');
|
await ajax.request('/foo');
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
|
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('XSRF behavior can be disabled', async () => {
|
it('XSRF behavior can be disabled', async () => {
|
||||||
const customHttp = new HttpClient({ xsrfCookieName: null, xsrfHeaderName: null });
|
const customAjax = new AjaxClient({ xsrfCookieName: null, xsrfHeaderName: null });
|
||||||
await customHttp.request('/foo');
|
await customAjax.request('/foo');
|
||||||
await http.request('/foo');
|
await ajax.request('/foo');
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.has('X-XSRF-TOKEN')).to.be.false;
|
expect(request.headers.has('X-XSRF-TOKEN')).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('XSRF token header and cookie can be customized', async () => {
|
it('XSRF token header and cookie can be customized', async () => {
|
||||||
const customHttp = new HttpClient({
|
const customAjax = new AjaxClient({
|
||||||
xsrfCookieName: 'CSRF-TOKEN',
|
xsrfCookieName: 'CSRF-TOKEN',
|
||||||
xsrfHeaderName: 'X-CSRF-TOKEN',
|
xsrfHeaderName: 'X-CSRF-TOKEN',
|
||||||
});
|
});
|
||||||
await customHttp.request('/foo');
|
await customAjax.request('/foo');
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('X-CSRF-TOKEN')).to.equal('5678');
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1,125 +1,15 @@
|
||||||
import { expect } from '@open-wc/testing';
|
import { expect } from '@open-wc/testing';
|
||||||
import sinon from 'sinon';
|
import { ajax, setAjax } from '../src/ajax.js';
|
||||||
|
import { AjaxClient } from '../src/AjaxClient.js';
|
||||||
import { ajax } from '../src/ajax.js';
|
|
||||||
|
|
||||||
describe('ajax', () => {
|
describe('ajax', () => {
|
||||||
/** @type {import('sinon').SinonFakeServer} */
|
it('exports an instance of AjaxClient', () => {
|
||||||
let server;
|
expect(ajax).to.be.an.instanceOf(AjaxClient);
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
server = sinon.fakeServer.create({ autoRespond: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it('can replace ajax with another instance', () => {
|
||||||
server.restore();
|
const newAjax = new AjaxClient();
|
||||||
});
|
setAjax(newAjax);
|
||||||
|
expect(ajax).to.equal(newAjax);
|
||||||
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('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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1
packages/ajax/test/foo.json
Normal file
1
packages/ajax/test/foo.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { expect } from '@open-wc/testing';
|
import { expect } from '@open-wc/testing';
|
||||||
import { localize } from '@lion/localize';
|
import { localize } from '@lion/localize';
|
||||||
import {
|
import {
|
||||||
createXSRFRequestTransformer,
|
createXSRFRequestInterceptor,
|
||||||
getCookie,
|
getCookie,
|
||||||
acceptLanguageRequestTransformer,
|
acceptLanguageRequestInterceptor,
|
||||||
} from '../src/transformers.js';
|
} from '../src/interceptors.js';
|
||||||
|
|
||||||
describe('transformers', () => {
|
describe('interceptors', () => {
|
||||||
describe('getCookie()', () => {
|
describe('getCookie()', () => {
|
||||||
it('returns the cookie value', () => {
|
it('returns the cookie value', () => {
|
||||||
expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
|
expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
|
||||||
|
|
@ -27,36 +27,36 @@ describe('transformers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('acceptLanguageRequestTransformer()', () => {
|
describe('acceptLanguageRequestInterceptor()', () => {
|
||||||
it('adds the locale as accept-language header', () => {
|
it('adds the locale as accept-language header', () => {
|
||||||
const request = new Request('/foo/');
|
const request = new Request('/foo/');
|
||||||
acceptLanguageRequestTransformer(request);
|
acceptLanguageRequestInterceptor(request);
|
||||||
expect(request.headers.get('accept-language')).to.equal(localize.locale);
|
expect(request.headers.get('accept-language')).to.equal(localize.locale);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not change an existing accept-language header', () => {
|
it('does not change an existing accept-language header', () => {
|
||||||
const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
|
const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
|
||||||
acceptLanguageRequestTransformer(request);
|
acceptLanguageRequestInterceptor(request);
|
||||||
expect(request.headers.get('accept-language')).to.equal('my-accept');
|
expect(request.headers.get('accept-language')).to.equal('my-accept');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createXSRFRequestTransformer()', () => {
|
describe('createXSRFRequestInterceptor()', () => {
|
||||||
it('adds the xsrf token header to the request', () => {
|
it('adds the xsrf token header to the request', () => {
|
||||||
const transformer = createXSRFRequestTransformer('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
const interceptor = createXSRFRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||||
cookie: 'XSRF-TOKEN=foo',
|
cookie: 'XSRF-TOKEN=foo',
|
||||||
});
|
});
|
||||||
const request = new Request('/foo/');
|
const request = new Request('/foo/');
|
||||||
transformer(request);
|
interceptor(request);
|
||||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
|
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doesnt set anything if the cookie is not there', () => {
|
it('doesnt set anything if the cookie is not there', () => {
|
||||||
const transformer = createXSRFRequestTransformer('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
const interceptor = createXSRFRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||||
cookie: 'XXSRF-TOKEN=foo',
|
cookie: 'XXSRF-TOKEN=foo',
|
||||||
});
|
});
|
||||||
const request = new Request('/foo/');
|
const request = new Request('/foo/');
|
||||||
transformer(request);
|
interceptor(request);
|
||||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
|
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
10
packages/ajax/types/ajaxClientTypes.d.ts
vendored
Normal file
10
packages/ajax/types/ajaxClientTypes.d.ts
vendored
Normal file
|
|
@ -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<RequestInit, 'body'> {
|
||||||
|
body?: BodyInit | null | Object;
|
||||||
|
}
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# HTTP
|
|
||||||
|
|
||||||
[//]: # 'AUTO INSERT HEADER PREPUBLISH'
|
|
||||||
|
|
||||||
`http` is a small wrapper around `fetch` which:
|
|
||||||
|
|
||||||
- Allows globally registering request and response transformers
|
|
||||||
- Throws on 4xx and 5xx status codes
|
|
||||||
- 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
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm i --save @lion/http
|
|
||||||
```
|
|
||||||
|
|
||||||
### Relation to fetch
|
|
||||||
|
|
||||||
`http` delegates all requests to fetch. `http.request` and `http.requestJson` have the same function signature as `window.fetch`, you can use any online resource to learn more about fetch. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) is a great start.
|
|
||||||
|
|
||||||
### Example requests
|
|
||||||
|
|
||||||
#### GET request
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { http } from '@lion/http';
|
|
||||||
|
|
||||||
const response = await http.request('/api/users');
|
|
||||||
const users = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST request
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { http } from '@lion/http';
|
|
||||||
|
|
||||||
const response = await http.request('/api/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username: 'steve' }),
|
|
||||||
});
|
|
||||||
const newUser = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### JSON requests
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
#### GET JSON request
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { http } from '@lion/http';
|
|
||||||
|
|
||||||
const { response, body } = await http.requestJson('/api/users');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST JSON request
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { http } from '@lion/http';
|
|
||||||
|
|
||||||
const { response, body } = await http.requestJson('/api/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { username: 'steve' },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error handling
|
|
||||||
|
|
||||||
Different from fetch, `http` throws when the server returns a 4xx or 5xx, returning the request and response:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { http } from '@lion/http';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const users = await http.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export { http, setHttp } from './src/http.js';
|
|
||||||
export { HttpClient } from './src/HttpClient.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
acceptLanguageRequestTransformer,
|
|
||||||
createXSRFRequestTransformer,
|
|
||||||
} from './src/transformers.js';
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@lion/http",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"description": "Thin wrapper around fetch.",
|
|
||||||
"author": "ing-bank",
|
|
||||||
"homepage": "https://github.com/ing-bank/lion/",
|
|
||||||
"license": "MIT",
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/ing-bank/lion.git",
|
|
||||||
"directory": "packages/http"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"prepublishOnly": "../../scripts/npm-prepublish.js"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"lion",
|
|
||||||
"web-components",
|
|
||||||
"fetch",
|
|
||||||
"ajax",
|
|
||||||
"http"
|
|
||||||
],
|
|
||||||
"main": "index.js",
|
|
||||||
"module": "index.js",
|
|
||||||
"files": [
|
|
||||||
"docs",
|
|
||||||
"src",
|
|
||||||
"stories",
|
|
||||||
"test",
|
|
||||||
"translations",
|
|
||||||
"*.js"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@lion/localize": "^0.4.14"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@open-wc/demoing-storybook": "^0.2.0",
|
|
||||||
"@open-wc/testing": "^2.3.4",
|
|
||||||
"sinon": "^7.2.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
/* eslint-disable consistent-return */
|
|
||||||
import { acceptLanguageRequestTransformer, createXSRFRequestTransformer } from './transformers.js';
|
|
||||||
import { HttpClientFetchError } from './HttpClientFetchError.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} HttpClientConfig configuration for the HttpClient 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} [xsrfCookieName] name of the XSRF cookie to read from
|
|
||||||
* @property {string} [xsrfHeaderName] name of the XSRF header to set
|
|
||||||
* @property {string} [jsonPrefix] the json prefix to use when fetching json (if any)
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms 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) => Request | Response} RequestTransformer
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms a Response before returning. Must return an instance of Response.
|
|
||||||
* @typedef {(response: Response) => Response} ResponseTransformer
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which
|
|
||||||
* transform 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 HttpClient {
|
|
||||||
/**
|
|
||||||
* @param {HttpClientConfig} config
|
|
||||||
*/
|
|
||||||
constructor(config = {}) {
|
|
||||||
const {
|
|
||||||
addAcceptLanguage = true,
|
|
||||||
xsrfCookieName = 'XSRF-TOKEN',
|
|
||||||
xsrfHeaderName = 'X-XSRF-TOKEN',
|
|
||||||
jsonPrefix,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
this._jsonPrefix = jsonPrefix;
|
|
||||||
/** @type {RequestTransformer[]} */
|
|
||||||
this._requestTransformers = [];
|
|
||||||
/** @type {ResponseTransformer[]} */
|
|
||||||
this._responseTransformers = [];
|
|
||||||
|
|
||||||
if (addAcceptLanguage) {
|
|
||||||
this.addRequestTransformer(acceptLanguageRequestTransformer);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xsrfCookieName && xsrfHeaderName) {
|
|
||||||
this.addRequestTransformer(createXSRFRequestTransformer(xsrfCookieName, xsrfHeaderName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {RequestTransformer} requestTransformer */
|
|
||||||
addRequestTransformer(requestTransformer) {
|
|
||||||
this._requestTransformers.push(requestTransformer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {RequestTransformer} requestTransformer */
|
|
||||||
removeRequestTransformer(requestTransformer) {
|
|
||||||
const indexOf = this._requestTransformers.indexOf(requestTransformer);
|
|
||||||
if (indexOf !== -1) {
|
|
||||||
this._requestTransformers.splice(indexOf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {ResponseTransformer} responseTransformer */
|
|
||||||
addResponseTransformer(responseTransformer) {
|
|
||||||
this._responseTransformers.push(responseTransformer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {ResponseTransformer} responseTransformer */
|
|
||||||
removeResponseTransformer(responseTransformer) {
|
|
||||||
const indexOf = this._responseTransformers.indexOf(responseTransformer);
|
|
||||||
if (indexOf !== -1) {
|
|
||||||
this._responseTransformers.splice(indexOf, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a fetch request, calling the registered fetch request and response
|
|
||||||
* transformers.
|
|
||||||
*
|
|
||||||
* @param {RequestInfo} info
|
|
||||||
* @param {RequestInit} [init]
|
|
||||||
* @returns {Promise<Response>}
|
|
||||||
*/
|
|
||||||
async request(info, init) {
|
|
||||||
const request = new Request(info, init);
|
|
||||||
/** @type {Request | Response} */
|
|
||||||
let transformedRequestOrResponse = request;
|
|
||||||
|
|
||||||
// run request transformers, returning directly and skipping the network
|
|
||||||
// if a transformer returns a Response
|
|
||||||
this._requestTransformers.forEach(transform => {
|
|
||||||
transformedRequestOrResponse = transform(transformedRequestOrResponse);
|
|
||||||
if (transformedRequestOrResponse instanceof Response) {
|
|
||||||
return transformedRequestOrResponse;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(transformedRequestOrResponse);
|
|
||||||
const transformedResponse = this._responseTransformers.reduce(
|
|
||||||
(prev, transform) => transform(prev),
|
|
||||||
response,
|
|
||||||
);
|
|
||||||
if (transformedResponse.status >= 400 && transformedResponse.status < 600) {
|
|
||||||
throw new HttpClientFetchError(request, transformedResponse);
|
|
||||||
}
|
|
||||||
return transformedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a fetch request, calling the registered fetch request and response
|
|
||||||
* transformers. Encodes/decodes the request and response body as JSON.
|
|
||||||
*
|
|
||||||
* @param {RequestInfo} info
|
|
||||||
* @param {RequestInit} [init]
|
|
||||||
* @template T
|
|
||||||
* @returns {Promise<{ response: Response, body: T }>}
|
|
||||||
*/
|
|
||||||
async requestJson(info, init) {
|
|
||||||
const jsonInit = {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
...(init && init.headers),
|
|
||||||
accept: 'application/json',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (init && init.body) {
|
|
||||||
jsonInit.headers['content-type'] = 'application/json';
|
|
||||||
jsonInit.body = JSON.stringify(init.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { HttpClient } from './HttpClient.js';
|
|
||||||
|
|
||||||
export let http = new HttpClient(); // eslint-disable-line import/no-mutable-exports
|
|
||||||
|
|
||||||
/**
|
|
||||||
* setHttp allows the Application Developer to override the globally used instance of {@link:http}.
|
|
||||||
* All interactions with {@link:http} after the call to setHttp will use this new instance
|
|
||||||
* (so make sure to call this method before dependant code using {@link:http} is ran and this
|
|
||||||
* method is not called by any of your (indirect) dependencies.)
|
|
||||||
* @param {HttpClient} newHttp the globally used instance of {@link:http}.
|
|
||||||
*/
|
|
||||||
export function setHttp(newHttp) {
|
|
||||||
http = newHttp;
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { localize } from '@lion/localize';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('./HttpClient').RequestTransformer} RequestTransformer
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} name the cookie name
|
|
||||||
* @param {Document} _document overwriteable for testing
|
|
||||||
* @returns {string | null}
|
|
||||||
*/
|
|
||||||
export function getCookie(name, _document = document) {
|
|
||||||
const match = _document.cookie.match(new RegExp(`(^|;\\s*)(${name})=([^;]*)`));
|
|
||||||
return match ? decodeURIComponent(match[3]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms a request, adding an accept-language header with the current application's locale
|
|
||||||
* if it has not already been set.
|
|
||||||
* @type {RequestTransformer}
|
|
||||||
*/
|
|
||||||
export function acceptLanguageRequestTransformer(request) {
|
|
||||||
if (!request.headers.has('accept-language')) {
|
|
||||||
request.headers.set('accept-language', localize.locale);
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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} _document overwriteable for testing
|
|
||||||
* @returns {RequestTransformer}
|
|
||||||
*/
|
|
||||||
export function createXSRFRequestTransformer(cookieName, headerName, _document = document) {
|
|
||||||
/**
|
|
||||||
* @type {RequestTransformer}
|
|
||||||
*/
|
|
||||||
function xsrfRequestTransformer(request) {
|
|
||||||
const xsrfToken = getCookie(cookieName, _document);
|
|
||||||
if (xsrfToken) {
|
|
||||||
request.headers.set(headerName, xsrfToken);
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
return xsrfRequestTransformer;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { expect } from '@open-wc/testing';
|
|
||||||
import { http, setHttp } from '../src/http.js';
|
|
||||||
import { HttpClient } from '../src/HttpClient.js';
|
|
||||||
|
|
||||||
describe('http', () => {
|
|
||||||
it('exports an instance of HttpClient', () => {
|
|
||||||
expect(http).to.be.an.instanceOf(HttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can replace http with another instance', () => {
|
|
||||||
const newHttp = new HttpClient();
|
|
||||||
setHttp(newHttp);
|
|
||||||
expect(http).to.equal(newHttp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue