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

feat: make requests extendable by adding requestMiddleware to provider options #1822

Merged
merged 19 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
nedsalk marked this conversation as resolved.
Show resolved Hide resolved
* @group browser
*/
describe('Provider', () => {
nedsalk marked this conversation as resolved.
Show resolved Hide resolved
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);
},
nedsalk marked this conversation as resolved.
Show resolved Hide resolved
});

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 };
danielbate marked this conversation as resolved.
Show resolved Hide resolved
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
Loading