Repository: alchemyplatform/alchemy-sdk-js
Pull request #79 (2022-07-26 17:27:14)

Add URL override to settings
This PR adds an optional `url` setting to `AlchemySettings` to allow specifying a custom hardcoded URL to send all requests to.

Other changes
- Refactored `AlchemyConfig` logic for returning the request URL
- To support this setting in the provider log, updated the constructors to take in the `AlchemyConfig` object instead of the `apiKey` and `Network` separately
- Some tests to check that the URL is overridden



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c24a823f..3a17bfe7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,14 @@
 # Changelog for Alchemy SDK for JavaScript
 
+## Unreleased
+
+### Minor Changes
+- Added an optional `url` setting to `AlchemySettings` to allow specifying a custom hardcoded URL to send all requests to.
+
+
 ## 2.0.0
 
-### Features
+### Major Changes
 
 - Top level package has moved from `@alch/alchemy-sdk` to `alchemy-sdk` for convenience. New command to install is `npm install alchemy-sdk`
 - Updated instantiation syntax: `initializeAlchemy(settings)` is now `new Alchemy(settings)`
diff --git a/src/api/alchemy-config.ts b/src/api/alchemy-config.ts
index 7843bd3b..2f63e0d9 100644
--- a/src/api/alchemy-config.ts
+++ b/src/api/alchemy-config.ts
@@ -2,6 +2,7 @@ import { AlchemySettings, Network } from '../types/types';
 import type { AlchemyProvider } from './alchemy-provider';
 import type { AlchemyWebSocketProvider } from './alchemy-websocket-provider';
 import {
+  AlchemyApiType,
   DEFAULT_ALCHEMY_API_KEY,
   DEFAULT_MAX_RETRIES,
   DEFAULT_NETWORK,
@@ -25,6 +26,12 @@ export class AlchemyConfig {
   /** The maximum number of retries to perform. */
   readonly maxRetries: number;
 
+  /**
+   * The optional hardcoded URL to send requests to instead of using the network
+   * and apiKey.
+   */
+  readonly url?: string;
+
   /**
    * Dynamically imported provider instance.
    *
@@ -45,16 +52,25 @@ export class AlchemyConfig {
     this.apiKey = config?.apiKey || DEFAULT_ALCHEMY_API_KEY;
     this.network = config?.network || DEFAULT_NETWORK;
     this.maxRetries = config?.maxRetries || DEFAULT_MAX_RETRIES;
+    this.url = config?.url;
   }
 
-  /** @internal */
-  _getBaseUrl(): string {
-    return getAlchemyHttpUrl(this.network, this.apiKey);
-  }
-
-  /** @internal */
-  _getNftUrl(): string {
-    return getAlchemyNftHttpUrl(this.network, this.apiKey);
+  /**
+   * Returns the URL endpoint to send the HTTP request to. If a custom URL was
+   * provided in the config, that URL is returned. Otherwise, the default URL is
+   * from the network and API key.
+   *
+   * @param apiType - The type of API to get the URL for.
+   * @internal
+   */
+  _getRequestUrl(apiType: AlchemyApiType): string {
+    if (this.url !== undefined) {
+      return this.url;
+    } else if (apiType === AlchemyApiType.NFT) {
+      return getAlchemyNftHttpUrl(this.network, this.apiKey);
+    } else {
+      return getAlchemyHttpUrl(this.network, this.apiKey);
+    }
   }
 
   /**
@@ -74,7 +90,7 @@ export class AlchemyConfig {
     if (!this._baseAlchemyProvider) {
       this._baseAlchemyProvider = (async () => {
         const { AlchemyProvider } = await import('./alchemy-provider');
-        return new AlchemyProvider(this.network, this.apiKey, this.maxRetries);
+        return new AlchemyProvider(this);
       })();
     }
     return this._baseAlchemyProvider;
@@ -100,7 +116,7 @@ export class AlchemyConfig {
         const { AlchemyWebSocketProvider } = await import(
           './alchemy-websocket-provider'
         );
-        return new AlchemyWebSocketProvider(this.network, this.apiKey);
+        return new AlchemyWebSocketProvider(this);
       })();
     }
     return this._baseAlchemyWssProvider;
diff --git a/src/api/alchemy-provider.ts b/src/api/alchemy-provider.ts
index 8b6aab5a..fb406f36 100644
--- a/src/api/alchemy-provider.ts
+++ b/src/api/alchemy-provider.ts
@@ -17,10 +17,14 @@ import {
 import { Network } from '../types/types';
 import { logWarn } from '../util/logger';
 import { VERSION } from '../version';
+import { AlchemyConfig } from './alchemy-config';
 
 /**
  * SDK's custom implementation of ethers.js's 'AlchemyProvider'.
  *
+ * Do not call this constructor directly. Instead, instantiate an instance of
+ * {@link Alchemy} and call {@link Alchemy.config.getProvider()}.
+ *
  * @public
  */
 export class AlchemyProvider
@@ -30,25 +34,33 @@ export class AlchemyProvider
   readonly apiKey: string;
   readonly maxRetries: number;
 
-  constructor(network: Networkish, apiKey: string, maxRetries: number) {
-    // Normalize the API Key to a string.
-    apiKey = AlchemyProvider.getApiKey(apiKey);
+  /** @internal */
+  constructor(config: AlchemyConfig) {
+    // If a hardcoded url was specified in the config, use that instead of the
+    // provided apiKey or network.
+    if (config.url !== undefined) {
+      super(config.url);
+    } else {
+      // Normalize the API Key to a string.
+      const apiKey = AlchemyProvider.getApiKey(config.apiKey);
+
+      // Generate our own connection info with the correct endpoint URLs.
+      const alchemyNetwork = AlchemyProvider.getAlchemyNetwork(config.network);
+      const connection = AlchemyProvider.getAlchemyConnectionInfo(
+        alchemyNetwork,
+        apiKey,
+        'http'
+      );
 
-    // Generate our own connection info with the correct endpoint URLs.
-    const alchemyNetwork = AlchemyProvider.getAlchemyNetwork(network);
-    const connection = AlchemyProvider.getAlchemyConnectionInfo(
-      alchemyNetwork,
-      apiKey,
-      'http'
-    );
+      // Normalize the Alchemy named network input to the network names used by
+      // ethers. This allows the parent super constructor in JsonRpcProvider to
+      // correctly set the network.
+      const ethersNetwork = EthersNetwork[alchemyNetwork];
+      super(connection, ethersNetwork);
+    }
 
-    // Normalize the Alchemy named network input to the network names used by
-    // ethers. This allows the parent super constructor in JsonRpcProvider to
-    // correctly set the network.
-    const ethersNetwork = EthersNetwork[alchemyNetwork];
-    super(connection, ethersNetwork);
-    this.apiKey = apiKey;
-    this.maxRetries = maxRetries;
+    this.apiKey = config.apiKey;
+    this.maxRetries = config.maxRetries;
   }
 
   /**
diff --git a/src/api/alchemy-websocket-provider.ts b/src/api/alchemy-websocket-provider.ts
index 8b171cb5..57e89c3b 100644
--- a/src/api/alchemy-websocket-provider.ts
+++ b/src/api/alchemy-websocket-provider.ts
@@ -1,5 +1,4 @@
 import { BigNumber } from '@ethersproject/bignumber';
-import { Networkish } from '@ethersproject/networks';
 import { DEFAULT_ALCHEMY_API_KEY, EthersNetwork, noop } from '../util/const';
 import { AlchemyProvider } from './alchemy-provider';
 import { Listener } from '@ethersproject/abstract-provider';
@@ -36,6 +35,7 @@ import {
   CommunityResourcable,
   WebSocketProvider
 } from '@ethersproject/providers';
+import { AlchemyConfig } from './alchemy-config';
 
 const HEARTBEAT_INTERVAL = 30000;
 const HEARTBEAT_WAIT_TIME = 10000;
@@ -53,6 +53,14 @@ const BACKFILL_RETRIES = 5;
  */
 const RETAINED_EVENT_BLOCK_COUNT = 10;
 
+/**
+ * SDK's custom implementation fo the ethers.js's 'AlchemyWebSocketProvider'.
+ *
+ * Do not call this constructor directly. Instead, instantiate an instance of
+ * {@link Alchemy} and call {@link Alchemy.config.getWebSocketProvider()}.
+ *
+ * @public
+ */
 export class AlchemyWebSocketProvider
   extends WebSocketProvider
   implements CommunityResourcable
@@ -78,21 +86,13 @@ export class AlchemyWebSocketProvider
   /** @internal */
   private cancelBackfill: () => void;
 
-  /**
-   * DO NOT CALL THIS CONSTRUCTOR DIRECTLY. Instead, use `Alchemy.getWebsocketProvider()`.
-   *
-   * @param network Requires one of the Alchemy `Network` enums
-   * @param apiKey The api key, or defaults to `demo`.
-   * @param wsConstructor Optional WebSocket constructor. Currently, used only
-   *   for testing purposes.
-   * @internal
-   */
-  constructor(network?: Networkish, apiKey?: any, wsConstructor?: any) {
+  /** @internal */
+  constructor(config: AlchemyConfig, wsConstructor?: any) {
     // Normalize the API Key to a string.
-    apiKey = AlchemyProvider.getApiKey(apiKey);
+    const apiKey = AlchemyProvider.getApiKey(config.apiKey);
 
     // Generate our own connection info with the correct endpoint URLs.
-    const alchemyNetwork = AlchemyProvider.getAlchemyNetwork(network);
+    const alchemyNetwork = AlchemyProvider.getAlchemyNetwork(config.network);
     const connection = AlchemyProvider.getAlchemyConnectionInfo(
       alchemyNetwork,
       apiKey,
@@ -100,7 +100,9 @@ export class AlchemyWebSocketProvider
     );
 
     const protocol = `alchemy-sdk-${VERSION}`;
-    const ws = new SturdyWebSocket(connection.url, protocol, {
+
+    // Use the provided config URL override if it exists, otherwise use the created one.
+    const ws = new SturdyWebSocket(config.url ?? connection.url, protocol, {
       wsConstructor: wsConstructor ?? getWebsocketConstructor()
     });
 
@@ -631,6 +633,7 @@ export class AlchemyWebSocketProvider
       backfillBuffer.length = 0;
     }
   }
+
   /**
    * Cancels the heartbeat and any pending backfills being performed. This is
    * called when the websocket connection goes down or is disconnected.
@@ -955,6 +958,7 @@ function makeCancelToken(): CancelToken {
 const MIN_RETRY_DELAY = 1000;
 const RETRY_BACKOFF_FACTOR = 2;
 const MAX_RETRY_DELAY = 30000;
+
 async function withBackoffRetries<T>(
   f: () => Promise<T>,
   retryCount: number,
diff --git a/src/internal/dispatch.ts b/src/internal/dispatch.ts
index 7a891a29..9614dc77 100644
--- a/src/internal/dispatch.ts
+++ b/src/internal/dispatch.ts
@@ -33,24 +33,11 @@ export async function requestHttpWithBackoff<Req, Res>(
         break;
       }
 
-      let response;
-      switch (apiType) {
-        case AlchemyApiType.NFT:
-          response = await sendAxiosRequest<Req, Res>(
-            config._getNftUrl(),
-            method,
-            params
-          );
-          break;
-        default:
-        case AlchemyApiType.BASE:
-          response = await sendAxiosRequest<Req, Res>(
-            config._getBaseUrl(),
-            method,
-            params
-          );
-          break;
-      }
+      const response = await sendAxiosRequest<Req, Res>(
+        config._getRequestUrl(apiType),
+        method,
+        params
+      );
 
       if (response.status === 200) {
         logDebug(method, `Successful request: ${method}`);
diff --git a/src/types/types.ts b/src/types/types.ts
index 014988b1..be1fc6b0 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -23,11 +23,22 @@ export interface AlchemySettings {
 
   /** The maximum number of retries to attempt if a request fails. Defaults to 5. */
   maxRetries?: number;
+
+  /**
+   * Optional URL endpoint to use for all requests. Setting this field will
+   * override the URL generated by the {@link network} and {@link apiKey} fields.
+   *
+   * This field is useful for testing or for using a custom node endpoint. Note
+   * that not all methods will work with custom URLs.
+   */
+  url?: string;
 }
 
 /**
  * The supported networks by Alchemy. Note that some functions are not available
- * on all networks. Please refer to the Alchemy documentation for more details.
+ * on all networks. Please refer to the Alchemy documentation for which APIs are
+ * available on which networks
+ * {@link https://docs.alchemy.com/alchemy/apis/feature-support-by-chain}
  *
  * @public
  */


Test patch:
diff --git a/test/unit/alchemy.test.ts b/test/unit/alchemy.test.ts
index f3bf4b7d..f4b51da6 100644
--- a/test/unit/alchemy.test.ts
+++ b/test/unit/alchemy.test.ts
@@ -1,5 +1,6 @@
 import { Alchemy, AlchemySettings, Network } from '../../src';
 import {
+  AlchemyApiType,
   DEFAULT_ALCHEMY_API_KEY,
   DEFAULT_MAX_RETRIES,
   DEFAULT_NETWORK
@@ -25,16 +26,19 @@ describe('Alchemy class', () => {
     const config: AlchemySettings = {
       apiKey: 'api-key-here',
       network: Network.OPT_KOVAN,
-      maxRetries: 2
+      maxRetries: 2,
+      url: 'invalid-url'
     };
     const alchemy = new Alchemy(config);
     config.apiKey = 'new-api-key';
     config.network = Network.OPT_MAINNET;
     config.maxRetries = 3;
+    config.url = 'another-url';
 
     expect(alchemy.config.apiKey).toEqual('api-key-here');
     expect(alchemy.config.network).toEqual(Network.OPT_KOVAN);
     expect(alchemy.config.maxRetries).toEqual(2);
+    expect(alchemy.config.url).toEqual('invalid-url');
   });
 
   it('initializes to default values', () => {
@@ -42,6 +46,7 @@ describe('Alchemy class', () => {
     expect(alchemy.config.apiKey).toEqual(DEFAULT_ALCHEMY_API_KEY);
     expect(alchemy.config.network).toEqual(DEFAULT_NETWORK);
     expect(alchemy.config.maxRetries).toEqual(DEFAULT_MAX_RETRIES);
+    expect(alchemy.config.url).toBeUndefined();
   });
 
   it('reuses the same provider', async () => {
@@ -69,4 +74,29 @@ describe('Alchemy class', () => {
     const wsProvider2 = await wsProvider2Promise;
     expect(wsProvider).toBe(wsProvider2);
   });
+
+  it('creates a request url based on the api type', () => {
+    const alchemy = new Alchemy({
+      network: Network.OPT_MAINNET,
+      apiKey: 'demo-key'
+    });
+
+    expect(alchemy.config._getRequestUrl(AlchemyApiType.NFT)).toEqual(
+      'https://opt-mainnet.g.alchemy.com/nft/v2/demo-key'
+    );
+    expect(alchemy.config._getRequestUrl(AlchemyApiType.BASE)).toEqual(
+      'https://opt-mainnet.g.alchemy.com/v2/demo-key'
+    );
+  });
+
+  it('uses the config url instead of apiKey/network if one was provided', () => {
+    const alchemy = new Alchemy({
+      apiKey: 'api-key-here',
+      network: Network.OPT_KOVAN,
+      url: 'custom-url'
+    });
+    expect(alchemy.config._getRequestUrl(AlchemyApiType.NFT)).toEqual(
+      'custom-url'
+    );
+  });
 });
diff --git a/test/unit/provider.test.ts b/test/unit/provider.test.ts
new file mode 100644
index 00000000..b2ac5abc
--- /dev/null
+++ b/test/unit/provider.test.ts
@@ -0,0 +1,13 @@
+import { Alchemy } from '../../src';
+
+describe('AlchemyProvider', () => {
+  it('accepts a URL hard override', async () => {
+    const alchemy = new Alchemy({
+      apiKey: 'demo-key',
+      url: 'hardcoded-url'
+    });
+    const provider = await alchemy.config.getProvider();
+    expect(provider.connection.url).toEqual('hardcoded-url');
+    expect(provider.apiKey).toEqual('demo-key');
+  });
+});
diff --git a/test/unit/websocket-provider.test.ts b/test/unit/websocket-provider.test.ts
index bda7f9e0..5099ed6b 100644
--- a/test/unit/websocket-provider.test.ts
+++ b/test/unit/websocket-provider.test.ts
@@ -1,4 +1,6 @@
 import {
+  Alchemy,
+  AlchemyConfig,
   AlchemyPendingTransactionsEventFilter,
   Network,
   toHex
@@ -21,13 +23,13 @@ import {
   WebsocketBackfiller
 } from '../../src/internal/websocket-backfiller';
 import { Formatter } from '@ethersproject/providers/lib/formatter';
-import SpyInstance = jest.SpyInstance;
 import { AlchemyProvider } from '../../src/api/alchemy-provider';
 import { noop } from '../../src/util/const';
 import {
   ALCHEMY_PENDING_TRANSACTIONS_EVENT_TYPE,
   EthersEvent
 } from '../../src/internal/internal-types';
+import SpyInstance = jest.SpyInstance;
 
 describe('AlchemyWebSocketProvider', () => {
   let wsProvider: Mocked<AlchemyWebSocketProvider>;
@@ -108,8 +110,11 @@ describe('AlchemyWebSocketProvider', () => {
       url: 'ws://localhost:1234'
     } as any);
     wsProvider = new AlchemyWebSocketProvider(
-      Network.ETH_MAINNET,
-      'demo',
+      {
+        network: Network.ETH_MAINNET,
+        apiKey: 'demo',
+        maxRetries: 5
+      } as AlchemyConfig,
       WebSocket
     ) as Mocked<AlchemyWebSocketProvider>;
   }
@@ -180,6 +185,18 @@ describe('AlchemyWebSocketProvider', () => {
     ).toEqual(0);
   });
 
+  it('accepts a hard URL override', async () => {
+    setupMockServer();
+    initializeWebSocketProvider();
+    const alchemy = new Alchemy({
+      apiKey: 'demo-key',
+      url: 'wss://hardcoded-url.com'
+    });
+    const provider = await alchemy.config.getWebSocketProvider();
+    expect(provider._websocket.url).toEqual('wss://hardcoded-url.com');
+    expect(provider.apiKey).toEqual('demo-key');
+  });
+
   describe('newHeads/on(block)', () => {
     it('handles default ethers subscriptions', done => {
       const sendSpy = jest.spyOn(AlchemyWebSocketProvider.prototype, 'send');