Skip to content

Commit

Permalink
feat: make requests extendable by adding requestMiddleware to provi…
Browse files Browse the repository at this point in the history
…der options (#1822)
  • Loading branch information
nedsalk committed Mar 7, 2024
1 parent ff24adc commit 950f9be
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/tasty-buses-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-ts/account": minor
---

Added `requestMiddleware` to `ProviderOptions` as a way to allow the user the modification of each fetch call's request
37 changes: 37 additions & 0 deletions apps/docs-snippets/src/guide/provider/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { FUEL_NETWORK_URL, Provider, sleep } from 'fuels';

async function fetchSomeExternalCredentials() {
return Promise.resolve('credential');
}

/**
* @group node
* @group browser
*/
describe('Provider', () => {
it('can be given options', async () => {
// #region provider-options
await Provider.create(FUEL_NETWORK_URL, {
fetch: async (url: string, requestInit: RequestInit | undefined) => {
// do something
await sleep(100);
return fetch(url, requestInit);
},
timeout: 2000,
cacheUtxo: 1500,
requestMiddleware: async (request: RequestInit) => {
const credentials = await fetchSomeExternalCredentials();
request.headers ??= {};
(request.headers as Record<string, string>).Authorization = credentials;

return request;
},
retryOptions: {
maxRetries: 5,
baseDelay: 100,
backoff: 'exponential',
},
});
// #endregion provider-options
});
});
4 changes: 4 additions & 0 deletions apps/docs/src/guide/providers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
An instance of the [`Provider`](../../api/Account/Provider.md) class lets you connect to a Fuel node. It provides read-only access to the blockchain state. You can use this provider as-is or through a [`Wallet`](../../api/Account/Wallet.md) instance.

<<< @/../../../packages/account/src/providers/provider.test.ts#provider-definition{ts:line-numbers}

You can also provide options to the `Provider`:

<<< @/../../docs-snippets/src/guide/provider/provider.test.ts#provider-options{ts:line-numbers}
119 changes: 108 additions & 11 deletions packages/account/src/providers/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
MESSAGE_PROOF,
} from '../../test/fixtures';

import type { ChainInfo, FetchRequestOptions, NodeInfo } from './provider';
import type { ChainInfo, NodeInfo } from './provider';
import Provider from './provider';
import type {
CoinTransactionRequestInput,
Expand All @@ -34,15 +34,8 @@ afterEach(() => {

const getCustomFetch =
(expectedOperationName: string, expectedResponse: object) =>
async (
url: string,
options: {
body: string;
headers: { [key: string]: string };
[key: string]: unknown;
}
) => {
const graphqlRequest = JSON.parse(options.body);
async (url: string, options: RequestInit | undefined) => {
const graphqlRequest = JSON.parse(options?.body as string);
const { operationName } = graphqlRequest;

if (operationName === expectedOperationName) {
Expand Down Expand Up @@ -209,7 +202,7 @@ describe('Provider', () => {
const providerUrl2 = 'http://127.0.0.1:8080/graphql';

const provider = await Provider.create(providerUrl1, {
fetch: (url: string, options: FetchRequestOptions) =>
fetch: (url: string, options?: RequestInit) =>
getCustomFetch('getVersion', { nodeInfo: { nodeVersion: url } })(url, options),
});

Expand Down Expand Up @@ -1045,4 +1038,108 @@ describe('Provider', () => {

await Promise.all(promises);
});

test('requestMiddleware modifies the request before being sent to the node [sync]', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
await Provider.create(FUEL_NETWORK_URL, {
requestMiddleware: (request) => {
request.headers ??= {};
(request.headers as Record<string, string>)['x-custom-header'] = 'custom-value';
return request;
},
});

const requestObject = fetchSpy.mock.calls[0][1];

expect(requestObject?.headers).toMatchObject({
'x-custom-header': 'custom-value',
});
});

test('requestMiddleware modifies the request before being sent to the node [async]', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
await Provider.create(FUEL_NETWORK_URL, {
requestMiddleware: (request) => {
request.headers ??= {};
(request.headers as Record<string, string>)['x-custom-header'] = 'custom-value';
return Promise.resolve(request);
},
});

const requestObject = fetchSpy.mock.calls[0][1];

expect(requestObject?.headers).toMatchObject({
'x-custom-header': 'custom-value',
});
});

test('requestMiddleware works for subscriptions', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const provider = await Provider.create(FUEL_NETWORK_URL, {
requestMiddleware: (request) => {
request.headers ??= {};
(request.headers as Record<string, string>)['x-custom-header'] = 'custom-value';
return request;
},
});

await safeExec(async () => {
for await (const iterator of provider.operations.statusChange({
transactionId: 'doesnt matter, will be aborted',
})) {
// Just running a subscription to trigger the middleware
// shouldn't be reached and should fail if reached
expect(iterator).toBeFalsy();
}
});

const subscriptionCall = fetchSpy.mock.calls.find((call) => call[0] === `${provider.url}-sub`);
const requestObject = subscriptionCall?.[1];

expect(requestObject?.headers).toMatchObject({
'x-custom-header': 'custom-value',
});
});

test('custom fetch works with requestMiddleware', async () => {
let requestHeaders: HeadersInit | undefined;
await Provider.create(FUEL_NETWORK_URL, {
fetch: async (url, requestInit) => {
requestHeaders = requestInit?.headers;
return fetch(url, requestInit);
},
requestMiddleware: (request) => {
request.headers ??= {};
(request.headers as Record<string, string>)['x-custom-header'] = 'custom-value';
return request;
},
});

expect(requestHeaders).toMatchObject({
'x-custom-header': 'custom-value',
});
});

test('custom fetch works with timeout', async () => {
const timeout = 500;
const provider = await Provider.create(FUEL_NETWORK_URL, {
fetch: async (url, requestInit) => fetch(url, requestInit),
timeout,
});
vi.spyOn(global, 'fetch').mockImplementationOnce((...args: unknown[]) =>
sleep(timeout).then(() =>
fetch(args[0] as RequestInfo | URL, args[1] as RequestInit | undefined)
)
);

const { error } = await safeExec(async () => {
await provider.getBlocks({});
});

expect(error).toMatchObject({
code: 23,
name: 'TimeoutError',
message: 'The operation was aborted due to timeout',
});
});
});
47 changes: 29 additions & 18 deletions packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,24 +209,35 @@ export type CursorPaginationArgs = {
before?: string | null;
};

export type FetchRequestOptions = {
method: 'POST';
headers: { [key: string]: string };
body: string;
};

/*
* Provider initialization options
*/
export type ProviderOptions = {
/**
* Custom fetch function to use for making requests.
*/
fetch?: (
url: string,
options: FetchRequestOptions,
providerOptions: Omit<ProviderOptions, 'fetch'>
requestInit?: RequestInit,
providerOptions?: Omit<ProviderOptions, 'fetch'>
) => Promise<Response>;
/**
* Timeout [ms] after which every request will be aborted.
*/
timeout?: number;
/**
* Cache UTXOs for the given time [ms].
*/
cacheUtxo?: number;
/**
* Retry options to use when fetching data from the node.
*/
retryOptions?: RetryOptions;
/**
* Middleware to modify the request before it is sent.
* This can be used to add headers, modify the body, etc.
*/
requestMiddleware?: (request: RequestInit) => RequestInit | Promise<RequestInit>;
};

/**
Expand Down Expand Up @@ -306,16 +317,18 @@ export default class Provider {
private static getFetchFn(options: ProviderOptions): NonNullable<ProviderOptions['fetch']> {
const { retryOptions, timeout } = options;

return autoRetryFetch((...args) => {
if (options.fetch) {
return options.fetch(...args);
}

return autoRetryFetch(async (...args) => {
const url = args[0];
const request = args[1];
const signal = timeout ? AbortSignal.timeout(timeout) : undefined;

return fetch(url, { ...request, signal });
let fullRequest: RequestInit = { ...request, signal };

if (options.requestMiddleware) {
fullRequest = await options.requestMiddleware(fullRequest);
}

return options.fetch ? options.fetch(url, fullRequest, options) : fetch(url, fullRequest);
}, retryOptions);
}

Expand Down Expand Up @@ -442,8 +455,7 @@ export default class Provider {
private createOperations() {
const fetchFn = Provider.getFetchFn(this.options);
const gqlClient = new GraphQLClient(this.url, {
fetch: (url: string, requestInit: FetchRequestOptions) =>
fetchFn(url, requestInit, this.options),
fetch: (url: string, requestInit: RequestInit) => fetchFn(url, requestInit, this.options),
});

const executeQuery = (query: DocumentNode, vars: Record<string, unknown>) => {
Expand All @@ -456,8 +468,7 @@ export default class Provider {
return new FuelGraphqlSubscriber({
url: this.url,
query,
fetchFn: (url, requestInit) =>
fetchFn(url as string, requestInit as FetchRequestOptions, this.options),
fetchFn: (url, requestInit) => fetchFn(url as string, requestInit, this.options),
variables: vars,
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { safeExec } from '@fuel-ts/errors/test-utils';

import type { FetchRequestOptions } from '../provider';

import type { RetryOptions } from './auto-retry-fetch';
import { autoRetryFetch, getWaitDelay } from './auto-retry-fetch';

Expand Down Expand Up @@ -57,7 +55,7 @@ describe('getWaitDelay', () => {

describe('autoRetryFetch', () => {
const url = 'http://anythibng.com';
const fetchOptions: FetchRequestOptions = { method: 'POST', headers: {}, body: '' };
const fetchOptions: RequestInit = { method: 'POST', headers: {}, body: '' };

const maxRetries = 5;
const baseDelay = 1;
Expand Down

0 comments on commit 950f9be

Please sign in to comment.