fix(ajax): rename to ajax, async interceptors

This commit is contained in:
Joren Broekema 2020-12-16 15:59:24 +01:00 committed by Thomas Allmer
parent c0659a8d5d
commit 4452d06d44
28 changed files with 584 additions and 7202 deletions

View 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.

View file

@ -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 const newUser = await response.json();
.post('zooApi/animals/addAnimal', body)
.then(response => {
console.log(`POST successful: ${response.status} ${response.statusText}`);
})
.catch(error => {
console.log(error);
});
``` ```
## 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);
})
.catch(error => {
console.log(error);
});
``` ```
### Additional headers #### POST JSON request
Add additional headers to the requests with the `headers` option. ```js
import { ajax } from '@lion/ajax';
```js preview-story const { response, body } = await ajax.requestJson('/api/users', {
export const additionalHeaders = () => html` method: 'POST',
<button body: { username: 'steve' },
@click=${() => { });
const myAjax = new AjaxClass({ headers: { 'MY-HEADER': 'SOME-HEADER-VALUE' } });
myAjax
.get('./packages/ajax/docs/assets/data.json')
.then(response => {
console.log(response);
})
.catch(error => {
console.log(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. ### Error handling
### Cancelable Request Different from fetch, `ajax` throws when the server returns a 4xx or 5xx, returning the request and response:
It is possible to make an Ajax request cancelable, and then call `cancel()` to make the request provide a custom error once fired. ```js
import { ajax } from '@lion/ajax';
```js preview-story try {
export const cancelableRequests = () => html` const users = await ajax.requestJson('/api/users');
<button } catch (error) {
@click=${() => { if (error.response) {
const myAjax = new AjaxClass({ cancelable: true }); if (error.response.status === 400) {
requestAnimationFrame(() => { // handle a specific status code, for example 400 bad request
myAjax.cancel('too slow'); } else {
}); console.error(error);
myAjax }
.get('./packages/ajax/docs/assets/data.json') } else {
.then(response => { // an error happened before receiving a response, ex. an incorrect request or network error
console.log(response.data); console.error(error);
}) }
.catch(error => { }
console.log(error);
});
}}
>
Execute Request to Action Logger
</button>
`;
``` ```
### Cancel concurrent requests ## Fetch Polyfill
You can cancel concurrent requests with the `cancelPreviousOnNewRequest` option. For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.
```js preview-story [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)
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

View file

@ -1,16 +0,0 @@
{
"animals": {
"cow": {
"type": "mammal",
"limbs": 4
},
"frog": {
"type": "amphibian",
"limbs": 4
},
"snake": {
"type": "reptile",
"limbs": 0
}
}
}

View file

@ -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';

View file

@ -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"
], ],

View file

@ -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;
}
},
};
}
}

View 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.`);
}
}
}

View file

@ -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;
} }

View file

@ -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;

View file

@ -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;
} }

View file

@ -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;
};
}

View file

@ -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' });
});
});
});

View file

@ -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);
});
});

View file

@ -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');
}
})(),
]);
});
});
});

View file

@ -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' });
});
});
});

View file

@ -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;
});
});
}); });

View file

@ -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');
}); });
}); });

View file

@ -0,0 +1 @@
{}

View file

@ -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);
}); });
}); });

View 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;
}

View file

@ -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);
}
}
```

View file

@ -1,7 +0,0 @@
export { http, setHttp } from './src/http.js';
export { HttpClient } from './src/HttpClient.js';
export {
acceptLanguageRequestTransformer,
createXSRFRequestTransformer,
} from './src/transformers.js';

View file

@ -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"
}
}

View file

@ -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.`);
}
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
});
});

5643
yarn.lock

File diff suppressed because it is too large Load diff