diff --git a/src/adapter/endpoint.ts b/src/adapter/endpoint.ts index e64ca34a..e775be02 100644 --- a/src/adapter/endpoint.ts +++ b/src/adapter/endpoint.ts @@ -68,7 +68,11 @@ export class AdapterEndpoint implements AdapterEndpo this.customInputValidation = params.customInputValidation this.customOutputValidation = params.customOutputValidation this.overrides = params.overrides - this.requestTransforms = [this.symbolOverrider.bind(this), ...(params.requestTransforms || [])] + this.requestTransforms = [ + this.symbolOverrider.bind(this), + this.normalizeInputCase.bind(this), + ...(params.requestTransforms || []), + ] } /** @@ -147,6 +151,27 @@ export class AdapterEndpoint implements AdapterEndpo return req } + /** + * Default request transform that normalizes base and quote input parameters to uppercase. + * Controlled by the NORMALIZE_CASE_INPUTS setting (default: true). + * This prevents subscription churn when the same asset is requested with different casings. + */ + normalizeInputCase( + req: AdapterRequest>, + settings: T['Settings'], + ): void { + if (!(settings as Record)['NORMALIZE_CASE_INPUTS']) { + return + } + const data = req.requestContext.data as Record + if (typeof data['base'] === 'string') { + data['base'] = data['base'].toUpperCase() + } + if (typeof data['quote'] === 'string') { + data['quote'] = data['quote'].toUpperCase() + } + } + getTransportNameForRequest( req: AdapterRequest>, settings: T['Settings'], diff --git a/src/config/index.ts b/src/config/index.ts index 17a93d92..5657c719 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -388,6 +388,12 @@ export const BaseSettingsDefinition = { 'Whether to enable debug enpoints (/debug/*) for this adapter. Enabling them might consume more resources.', default: false, }, + NORMALIZE_CASE_INPUTS: { + type: 'boolean', + description: + 'When true, normalizes base and quote input parameters to uppercase before cache key computation and subscription registration. Set to false for adapters that require case-sensitive asset identifiers.', + default: true, + }, } as const satisfies SettingsDefinitionMap export const buildAdapterSettings = < diff --git a/test/normalize-case.test.ts b/test/normalize-case.test.ts new file mode 100644 index 00000000..021e452f --- /dev/null +++ b/test/normalize-case.test.ts @@ -0,0 +1,348 @@ +import untypedTest, { TestFn } from 'ava' +import { Adapter, AdapterEndpoint } from '../src/adapter' +import { AdapterConfig } from '../src/config' +import { ResponseCache } from '../src/cache/response' +import { Transport, TransportGenerics } from '../src/transports' +import { AdapterRequest } from '../src/util' +import { InputParameters } from '../src/validation' +import { TypeFromDefinition } from '../src/validation/input-params' +import { TestAdapter } from '../src/util/testing-utils' + +type Pair = { + base: string + quote: string +} + +const inputParameters = new InputParameters({ + base: { + type: 'string', + description: 'base', + required: true, + }, + quote: { + type: 'string', + description: 'quote', + required: true, + }, +}) + +type TestTransportGenerics = TransportGenerics & { + Parameters: typeof inputParameters.definition + Response: { + Data: Pair + } +} + +class EchoTransport implements Transport { + name!: string + responseCache!: ResponseCache + async initialize() { + return + } + async foregroundExecute( + req: AdapterRequest>, + ) { + return { + data: { + base: req.requestContext.data.base, + quote: req.requestContext.data.quote, + }, + statusCode: 200, + result: null, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } +} + +const test = untypedTest as TestFn<{ + testAdapter: TestAdapter +}> + +test.afterEach(() => { + delete process.env['NORMALIZE_CASE_INPUTS'] +}) + +test.serial('normalizes base and quote to uppercase by default', async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'USDe', + quote: 'usd', + }) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().data, { + base: 'USDE', + quote: 'USD', + }) +}) + +test.serial('mixed-case requests for same asset resolve to same response', async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response1 = await testAdapter.request({ base: 'USDe', quote: 'USD' }) + const response2 = await testAdapter.request({ base: 'USDE', quote: 'USD' }) + const response3 = await testAdapter.request({ base: 'usde', quote: 'usd' }) + + t.is(response1.statusCode, 200) + t.is(response2.statusCode, 200) + t.is(response3.statusCode, 200) + + t.deepEqual(response1.json().data, { base: 'USDE', quote: 'USD' }) + t.deepEqual(response2.json().data, { base: 'USDE', quote: 'USD' }) + t.deepEqual(response3.json().data, { base: 'USDE', quote: 'USD' }) +}) + +test.serial('does not normalize when NORMALIZE_CASE_INPUTS is false', async (t) => { + process.env['NORMALIZE_CASE_INPUTS'] = 'false' + + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'USDe', + quote: 'usd', + }) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().data, { + base: 'USDe', + quote: 'usd', + }) +}) + +test.serial('adapter can opt out via envDefaultOverrides', async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + config: new AdapterConfig( + {}, + { + envDefaultOverrides: { + NORMALIZE_CASE_INPUTS: false, + }, + }, + ), + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'USDe', + quote: 'usd', + }) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().data, { + base: 'USDe', + quote: 'usd', + }) +}) + +test.serial('normalization runs after symbolOverrider', async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + overrides: { + WBTC: 'btc', + }, + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'WBTC', + quote: 'USD', + }) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().data, { + base: 'BTC', + quote: 'USD', + }) +}) + +test.serial('normalization does not affect non-base/quote string params', async (t) => { + const extendedInputParameters = new InputParameters({ + base: { + type: 'string', + description: 'base', + required: true, + }, + quote: { + type: 'string', + description: 'quote', + required: true, + }, + market: { + type: 'string', + description: 'market identifier', + }, + }) + + type ExtendedGenerics = TransportGenerics & { + Parameters: typeof extendedInputParameters.definition + Response: { + Data: { base: string; quote: string; market?: string } + } + } + + class ExtendedEchoTransport implements Transport { + name!: string + responseCache!: ResponseCache + async initialize() { + return + } + async foregroundExecute( + req: AdapterRequest>, + ) { + return { + data: { + base: req.requestContext.data.base, + quote: req.requestContext.data.quote, + market: req.requestContext.data.market, + }, + statusCode: 200, + result: null, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + } + + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters: extendedInputParameters, + transport: new ExtendedEchoTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'eth', + quote: 'usd', + market: 'nyse_arca', + }) + + t.is(response.statusCode, 200) + const data = response.json().data + t.is(data.base, 'ETH') + t.is(data.quote, 'USD') + t.is(data.market, 'nyse_arca') +}) + +test.serial('already uppercase inputs are unchanged', async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'ETH', + quote: 'USD', + }) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().data, { + base: 'ETH', + quote: 'USD', + }) +}) + +test.serial('adapter-specific requestTransform runs after normalization', async (t) => { + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test', + endpoints: [ + new AdapterEndpoint({ + name: 'test', + inputParameters, + transport: new EchoTransport(), + requestTransforms: [ + (req) => { + const data = req.requestContext.data as Record + data['base'] = data['base'].toLowerCase() + data['quote'] = data['quote'].toLowerCase() + }, + ], + }), + ], + }) + + const testAdapter = await TestAdapter.start(adapter, t.context) + const response = await testAdapter.request({ + base: 'ETH', + quote: 'USD', + }) + + t.is(response.statusCode, 200) + t.deepEqual(response.json().data, { + base: 'eth', + quote: 'usd', + }) +}) diff --git a/test/overrides.test.ts b/test/overrides.test.ts index 7b98f7b4..61a0c09d 100644 --- a/test/overrides.test.ts +++ b/test/overrides.test.ts @@ -67,6 +67,7 @@ class OverrideTestTransport implements Transport { } test.beforeEach(async (t) => { + process.env['NORMALIZE_CASE_INPUTS'] = 'false' const adapter = new Adapter({ name: 'TEST_ADAPTER', defaultEndpoint: 'test-endpoint',