Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Support for Middleware Functions in piral-fetch #655

Merged
merged 9 commits into from
Dec 13, 2023
42 changes: 42 additions & 0 deletions src/plugins/piral-fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,48 @@ const instance = createInstance({

**Note**: `piral-fetch` plays nicely together with authentication providers such as `piral-adal`. As such authentication tokens are automatically inserted on requests to the base URL.

### Middlewares

`piral-fetch` allows you to configure middleware functions which are executed on each `fetch` call. Middleware functions receive the same parameters as `fetch`, plus a `next` function which calls either the next middleware or the actual `fetch` function. The following code shows an exemplary middleware which logs when requests start and finish:

```ts
const logRequests: FetchMiddleware = async (
path: string,
options: FetchOptions,
next: PiletFetchApiFetch,
): Promise<FetchResponse<any>> => {
try {
console.log(`Making request to ${path}...`);
const response = await next(path, options);
console.log(`Request to ${path} returned status code ${response.code}.`);
return response;
} catch (e) {
console.error(`Request to ${path} threw an error: `, e);
throw e;
}
};
```

Middlewares must be configured in the Piral instance:

```ts
const instance = createInstance({
plugins: [createFetchApi({
// important part
middlewares: [
firstMiddleware,
secondMiddleware,
thirdMiddleware,
logRequests,
],
// ...other options...
})],
// ...
});
```

Middlewares are invoked in a top-down order. In the above example, this means that `firstMiddleware` is invoked first, then `secondMiddleware`, then `thirdMiddleware`, then `logRequests` and finally the actual `fetch` function.

:::

## License
Expand Down
19 changes: 19 additions & 0 deletions src/plugins/piral-fetch/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import type { FetchOptions, FetchResponse, PiletFetchApiFetch } from './types';

export interface FetchMiddleware {
/**
* A middleware function for the fetch API.
* @param path The target of the fetch.
* @param options The options to be used.
* @param next A function that invokes the next middleware or the final `fetch`.
* @returns The promise waiting for the response to arrive.
*/
(path: string, options: FetchOptions, next: PiletFetchApiFetch): Promise<FetchResponse<any>>;
}

export interface FetchConfig {
/**
* Sets the default request init settings.
Expand All @@ -9,4 +22,10 @@ export interface FetchConfig {
* @default location.origin
*/
base?: string;
/**
* An ordered list of middleware functions which can intercept and transform any request made via `piral-fetch`.
* Middleware functions are executed in a top-down order for each fetch request.
* @default []
*/
middlewares?: Array<FetchMiddleware>;
}
44 changes: 44 additions & 0 deletions src/plugins/piral-fetch/src/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,48 @@ describe('Create fetch API Module', () => {
const result = response.body;
expect(result.substr(0, 5)).toBe(`<?xml`);
});

it('invokes configured middleware function and calls API', async () => {
const context = { emit: vitest.fn() } as any;
const middleware = vitest.fn((path, options, next) => next(path, options));
const { fetch } = createFetchApi({
base: `http://localhost:${port}`,
middlewares: [middleware],
})(context) as any;
const response = await fetch('json');
const result = response.body;
expect(Array.isArray(result)).toBeTruthy();
expect(result.length).toBe(10);
expect(result[0]).toBe(1);
expect(middleware).toHaveBeenCalledOnce();
});

it('invokes middleware functions in top-down order', async () => {
const context = { emit: vitest.fn() } as any;
const invocationOrder: Array<number> = [];
const createMiddleware = (myPosition: number) => (path, options, next) => {
invocationOrder.push(myPosition);
return next(path, options);
};
const { fetch } = createFetchApi({
base: `http://localhost:${port}`,
middlewares: [createMiddleware(1), createMiddleware(2), createMiddleware(3)],
})(context) as any;
await fetch('json');
expect(invocationOrder).toEqual([1, 2, 3]);
});

it('allows middleware functions to terminate middleware chain', async () => {
const context = { emit: vitest.fn() } as any;
const expectedResponse = { code: 200, body: 'Terminated by middleware', text: 'Terminated by middleware' };
const middleware = () => Promise.resolve(expectedResponse);
const { fetch } = createFetchApi({
base: `http://localhost:${port}`,
middlewares: [middleware],
})(context) as any;
const globalFetch = vitest.spyOn(global, 'fetch');
const response = await fetch('json');
expect(response).toBe(expectedResponse);
expect(globalFetch).not.toHaveBeenCalled();
});
});
99 changes: 63 additions & 36 deletions src/plugins/piral-fetch/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,74 @@
import { FetchConfig } from './config';
import { FetchOptions, FetchResponse } from './types';
import { FetchConfig, FetchMiddleware } from './config';
import { FetchOptions, FetchResponse, PiletFetchApiFetch } from './types';

const headerAccept = 'accept';
const headerContentType = 'content-type';
const mimeApplicationJson = 'application/json';

export function httpFetch<T>(config: FetchConfig, path: string, options: FetchOptions = {}): Promise<FetchResponse<T>> {
const baseInit = config.default || {};
const baseHeaders = baseInit.headers || {};
const baseUrl = config.base || location.origin;
const { method = 'get', body, headers = {}, cache = baseInit.cache, mode = baseInit.mode, result = 'auto', signal } = options;
const json =
Array.isArray(body) ||
typeof body === 'number' ||
(typeof body === 'object' && body instanceof FormData === false && body instanceof Blob === false);
const url = new URL(path, baseUrl);
const init: RequestInit = {
...baseInit,
method,
body: json ? JSON.stringify(body) : (body as BodyInit),
headers: {
...baseHeaders,
...headers,
},
cache,
mode,
signal,
};
// fetcher makes the actual HTTP request.
// It is used as the last step in the upcoming middleware chain and does *not* call/require next
// (which is undefined in this case).
const fetcher: FetchMiddleware = (path, options) => {
const baseInit = config.default || {};
const baseHeaders = baseInit.headers || {};
const baseUrl = config.base || location.origin;
const {
method = 'get',
body,
headers = {},
cache = baseInit.cache,
mode = baseInit.mode,
result = 'auto',
signal,
} = options;
const json =
Array.isArray(body) ||
typeof body === 'number' ||
(typeof body === 'object' && body instanceof FormData === false && body instanceof Blob === false);
const url = new URL(path, baseUrl);
const init: RequestInit = {
...baseInit,
method,
body: json ? JSON.stringify(body) : (body as BodyInit),
headers: {
...baseHeaders,
...headers,
},
cache,
mode,
signal,
};

if (json) {
init.headers[headerContentType] = 'application/json';
init.headers[headerAccept] = mimeApplicationJson;
}
if (json) {
init.headers[headerContentType] = 'application/json';
init.headers[headerAccept] = mimeApplicationJson;
}

return fetch(url.href, init).then((res) => {
const contentType = res.headers.get(headerContentType);
const json = result === 'json' || (result === 'auto' && !!contentType && contentType.indexOf('json') !== -1);
const promise = json ? res.json() : res.text();
return fetch(url.href, init).then((res) => {
const contentType = res.headers.get(headerContentType);
const json = result === 'json' || (result === 'auto' && !!contentType && contentType.indexOf('json') !== -1);
const promise = json ? res.json() : res.text();

return promise.then((body) => ({
body,
code: res.status,
text: res.statusText,
}));
return promise.then((body) => ({
body,
code: res.status,
text: res.statusText,
}));
});
};

// Prepare the middleware chain. Middlewares are called in a top-down order.
// Every configured middleware function must receive a `next` function with the same shape
// as a `fetch` function. `next` invokes the next function in the chain.
// `fetcher` from above is the last function in the chain and always terminates it.
const middlewareFns = [...(config.middlewares || []), fetcher];
let middlewareChain: Array<PiletFetchApiFetch>;
middlewareChain = middlewareFns.map((middleware, i) => {
const next: PiletFetchApiFetch = (path, options) => middlewareChain[i + 1](path, options);
const invoke: PiletFetchApiFetch = (path, options) => middleware(path, options, next);
return invoke;
});

return middlewareChain[0](path, options);
}
Loading