diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d6f27d8..4c76f96 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,6 +10,9 @@ Fixes: #__issue__ ## Description [//]: # (Describe your changes in detail) +## How has this been documented? +[//]: # (Please describe how you documented the developer impact of your changes; link to PRs or issues or explan why no documentation changes are required) + ## How has this been tested? [//]: # (Please describe in detail how you tested your changes) diff --git a/package.json b/package.json index 45ffc7d..579dc7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.14.4", + "version": "4.15.0", "description": "Common library for Eppo JavaScript SDKs (web, react native, and node)", "main": "dist/index.js", "files": [ diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index d77e7b2..ab2252f 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -1,45 +1,249 @@ +import * as td from 'testdouble'; + import ApiEndpoints from './api-endpoints'; -import { BASE_URL as DEFAULT_BASE_URL } from './constants'; +import { BASE_URL as DEFAULT_BASE_URL, DEFAULT_EVENT_DOMAIN } from './constants'; +import SdkTokenDecoder from './sdk-token-decoder'; describe('ApiEndpoints', () => { - it('should append query parameters to the URL', () => { - const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://api.example.com', - queryParams: { - apiKey: '12345', - sdkVersion: 'foobar', - sdkName: 'ExampleSDK', + describe('Query parameters', () => { + describe('should correctly handle query parameters in various scenarios', () => { + const testCases = [ + { + name: 'with custom base URL and query params', + params: { + baseUrl: 'http://api.example.com', + queryParams: { + apiKey: '12345', + sdkVersion: 'foobar', + sdkName: 'ExampleSDK', + }, + }, + expected: + 'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', + }, + { + name: 'with default base URL and query params', + params: { + queryParams: { + apiKey: '12345', + sdkVersion: 'foobar', + sdkName: 'ExampleSDK', + }, + }, + expected: `${DEFAULT_BASE_URL}/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK`, + }, + { + name: 'without query params', + params: {}, + expected: `${DEFAULT_BASE_URL}/flag-config/v1/config`, + }, + { + name: 'with special characters in query params', + params: { + queryParams: { + apiKey: 'test-key', + sdkName: 'value with spaces', + sdkVersion: 'a+b=c&d', + }, + }, + expected: + 'https://fscdn.eppo.cloud/api/flag-config/v1/config?apiKey=test-key&sdkName=value+with+spaces&sdkVersion=a%2Bb%3Dc%26d', + }, + ]; + + testCases.forEach(({ name, params, expected }) => { + it(`${name}`, () => { + const apiEndpoints = new ApiEndpoints(params); + const result = apiEndpoints.ufcEndpoint(); + + expect(result).toEqual(expected); + }); + }); + }); + }); + + describe('Base URL determination', () => { + const testCases = [ + { + name: 'should use custom baseUrl when provided', + params: { baseUrl: 'https://custom-domain.com' }, + expected: 'https://custom-domain.com/assignments', + }, + { + name: 'should use subdomain from SDK token when valid', + params: { sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4=') }, + expected: 'https://test-subdomain.fscdn.eppo.cloud/api/assignments', + }, + { + name: 'should prefer custom baseUrl over SDK token subdomain', + params: { + baseUrl: 'https://custom-domain.com', + sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='), + }, + expected: 'https://custom-domain.com/assignments', + }, + { + name: 'should not allow custom baseUrl to be the default base url', + params: { + baseUrl: DEFAULT_BASE_URL, + sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='), + }, + expected: 'https://test-subdomain.fscdn.eppo.cloud/api/assignments', + }, + { + name: 'should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', + params: { sdkTokenDecoder: new SdkTokenDecoder('abc.ZWg9ZXZlbnQtaG9zdG5hbWU=') }, + expected: 'https://fscdn.eppo.cloud/api/assignments', }, + { + name: 'should fallback to DEFAULT_BASE_URL when SDK token has nothing encoded', + params: { sdkTokenDecoder: new SdkTokenDecoder('invalid-token') }, + expected: 'https://fscdn.eppo.cloud/api/assignments', + }, + ]; + + testCases.forEach(({ name, params, expected }) => { + it(name, () => { + const endpoints = new ApiEndpoints(params); + const result = endpoints.precomputedFlagsEndpoint(); + + expect(result).toBe(expected); + }); }); - expect(apiEndpoints.endpoint('/data').toString()).toEqual( - 'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', - ); - expect(apiEndpoints.ufcEndpoint().toString()).toEqual( - 'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', - ); }); - it('should use default base URL if not provided', () => { - const apiEndpoints = new ApiEndpoints({ - queryParams: { - apiKey: '12345', - sdkVersion: 'foobar', - sdkName: 'ExampleSDK', + describe('Endpoint URL construction', () => { + const sdkTokenDecoder = new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain + + const endpointTestCases = [ + { + name: 'UFC endpoint with subdomain', + factory: (api: ApiEndpoints) => api.ufcEndpoint(), + expected: 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/config', + }, + { + name: 'bandit parameters endpoint with subdomain', + factory: (api: ApiEndpoints) => api.banditParametersEndpoint(), + expected: 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/bandits', }, + ]; + + endpointTestCases.forEach(({ name, factory, expected }) => { + it(name, () => { + const endpoints = new ApiEndpoints({ sdkTokenDecoder: sdkTokenDecoder }); + const result = factory(endpoints); + expect(result).toBe(expected); + }); }); - expect(apiEndpoints.endpoint('/data').toString()).toEqual( - `${DEFAULT_BASE_URL}/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK`, - ); - expect(apiEndpoints.ufcEndpoint().toString()).toEqual( - `${DEFAULT_BASE_URL}/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK`, - ); }); - it('should not append query parameters if not provided', () => { - const apiEndpoints = new ApiEndpoints({}); - expect(apiEndpoints.endpoint('/data').toString()).toEqual(`${DEFAULT_BASE_URL}/data`); - expect(apiEndpoints.ufcEndpoint().toString()).toEqual( - `${DEFAULT_BASE_URL}/flag-config/v1/config`, + describe('Event ingestion URL', () => { + const hostnameToken = new SdkTokenDecoder( + 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', ); + let mockedDecoder: SdkTokenDecoder; + + beforeEach(() => { + mockedDecoder = td.object(); + td.when(mockedDecoder.isValid()).thenReturn(true); + }); + + const eventUrlTestCases = [ + { + name: 'should decode the event ingestion hostname from the SDK key', + setupDecoder: () => hostnameToken, + expected: 'https://123456.e.testing.eppo.cloud/v0/i', + }, + { + name: 'should decode strings with non URL-safe characters', + setupDecoder: () => { + td.when(mockedDecoder.getEventIngestionHostname()).thenReturn( + '12 3456/.e.testing.eppo.cloud', + ); + return mockedDecoder; + }, + expected: 'https://12 3456/.e.testing.eppo.cloud/v0/i', + }, + { + name: 'should return null if the SDK key is invalid', + setupDecoder: () => { + td.when(mockedDecoder.isValid()).thenReturn(false); + return mockedDecoder; + }, + expected: null, + }, + { + name: 'should use subdomain with DEFAULT_EVENT_DOMAIN when hostname is not available', + setupDecoder: () => { + td.when(mockedDecoder.getEventIngestionHostname()).thenReturn(null); + td.when(mockedDecoder.getSubdomain()).thenReturn('test-subdomain'); + return mockedDecoder; + }, + expected: `https://test-subdomain.${DEFAULT_EVENT_DOMAIN}/v0/i`, + }, + { + name: 'should prioritize hostname over subdomain if both are available', + setupDecoder: () => { + td.when(mockedDecoder.getEventIngestionHostname()).thenReturn('event-host.example.com'); + td.when(mockedDecoder.getSubdomain()).thenReturn('test-subdomain'); + return mockedDecoder; + }, + expected: 'https://event-host.example.com/v0/i', + }, + { + name: 'should return null when token is valid but no hostname or subdomain is available', + setupDecoder: () => { + td.when(mockedDecoder.getEventIngestionHostname()).thenReturn(null); + td.when(mockedDecoder.getSubdomain()).thenReturn(null); + return mockedDecoder; + }, + expected: null, + }, + ]; + + eventUrlTestCases.forEach(({ name, setupDecoder, expected }) => { + it(name, () => { + const decoder = setupDecoder(); + const endpoints = new ApiEndpoints({ sdkTokenDecoder: decoder }); + expect(endpoints.eventIngestionEndpoint()).toEqual(expected); + }); + }); + }); + + describe('URL normalization', () => { + const urlNormalizationTestCases = [ + { + name: 'preserve http:// protocol', + baseUrl: 'http://example.com', + expected: 'http://example.com/flag-config/v1/config', + }, + { + name: 'preserve https:// protocol', + baseUrl: 'https://example.com', + expected: 'https://example.com/flag-config/v1/config', + }, + { + name: 'preserve // protocol', + baseUrl: '//example.com', + expected: '//example.com/flag-config/v1/config', + }, + { + name: 'add https:// to URLs without protocols', + baseUrl: 'example.com', + expected: 'https://example.com/flag-config/v1/config', + }, + { + name: 'handle multiple slashes', + baseUrl: 'example.com/', + expected: 'https://example.com/flag-config/v1/config', + }, + ]; + + urlNormalizationTestCases.forEach(({ name, baseUrl, expected }) => { + it(`should ${name}`, () => { + const endpoints = new ApiEndpoints({ baseUrl }); + expect(endpoints.ufcEndpoint()).toEqual(expected); + }); + }); }); }); diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index a7297f6..a28f464 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -1,42 +1,186 @@ import { - BASE_URL as DEFAULT_BASE_URL, - UFC_ENDPOINT, BANDIT_ENDPOINT, + BASE_URL, + DEFAULT_EVENT_DOMAIN, + EVENT_ENDPOINT, PRECOMPUTED_FLAGS_ENDPOINT, + UFC_ENDPOINT, } from './constants'; import { IQueryParams, IQueryParamsWithSubject } from './http-client'; +import SdkTokenDecoder from './sdk-token-decoder'; +/** + * Parameters for configuring the API endpoints + * + * @param queryParams Query parameters to append to the configuration endpoints + * @param baseUrl Custom base URL for configuration endpoints (optional) + * @param defaultUrl Default base URL for configuration endpoints (defaults to BASE_URL) + * @param sdkTokenDecoder SDK token decoder for subdomain and event hostname extraction + */ interface IApiEndpointsParams { queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; + defaultUrl: string; + sdkTokenDecoder?: SdkTokenDecoder; } -/** Utility class for constructing an Eppo API endpoint URL given a provided baseUrl and query parameters */ +/** + * Utility class for constructing Eppo API endpoint URLs. + * + * This class handles two distinct types of endpoints: + * 1. Configuration endpoints (UFC, bandits, precomputed flags) - based on the effective base URL + * which considers baseUrl, subdomain from SDK token, and defaultUrl in that order. + * 2. Event ingestion endpoint - either uses the default event domain with subdomain from SDK token + * or a full hostname from SDK token. This endpoint IGNORES the baseUrl and defaultUrl parameters. + * + * For event ingestion endpoints, consider using the static helper method: + * `ApiEndpoints.createEventIngestionUrl(sdkKey)` + */ export default class ApiEndpoints { - constructor(private readonly params: IApiEndpointsParams) { - this.params.baseUrl = params.baseUrl ?? DEFAULT_BASE_URL; + private readonly sdkToken: SdkTokenDecoder | null; + private readonly _effectiveBaseUrl: string; + private readonly params: IApiEndpointsParams; + + constructor(params: Partial) { + this.params = Object.assign({}, { defaultUrl: BASE_URL }, params); + this.sdkToken = params.sdkTokenDecoder ?? null; + this._effectiveBaseUrl = this.determineBaseUrl(); + } + + /** + * Helper method to return an event ingestion endpoint URL from the customer's SDK token. + * @param sdkToken + */ + static createEventIngestionUrl(sdkToken: string): string | null { + return new ApiEndpoints({ + sdkTokenDecoder: new SdkTokenDecoder(sdkToken), + }).eventIngestionEndpoint(); } - endpoint(resource: string): string { - const endpointUrl = `${this.params.baseUrl}${resource}`; + /** + * Normalizes a URL by ensuring proper protocol and removing trailing slashes + */ + private normalizeUrl(url: string, protocol = 'https://'): string { + const protocolMatch = url.match(/^(https?:\/\/|\/\/)/i); + + if (protocolMatch) { + return url; + } + return `${protocol}${url}`; + } + + private joinUrlParts(...parts: string[]): string { + return parts + .map((part) => part.trim()) + .map((part, i) => { + // For first part, remove trailing slash + if (i === 0) return part.replace(/\/+$/, ''); + // For other parts, remove leading and trailing slashes + return part.replace(/^\/+|\/+$/g, ''); + }) + .join('/'); + } + + /** + * Determines the effective base URL for configuration endpoints based on: + * 1. If baseUrl is provided, and it is not equal to the DEFAULT_BASE_URL, use it + * 2. If the api key contains an encoded customer-specific subdomain, use it with DEFAULT_DOMAIN + * 3. Otherwise, fall back to DEFAULT_BASE_URL + * + * @returns The effective base URL to use for configuration endpoints + */ + private determineBaseUrl(): string { + // If baseUrl is explicitly provided and different from default, use it + if (this.params.baseUrl && this.params.baseUrl !== this.params.defaultUrl) { + return this.normalizeUrl(this.params.baseUrl); + } + + // If there's a valid SDK token with a subdomain, use it + const subdomain = this.sdkToken?.getSubdomain(); + if (subdomain && this.sdkToken?.isValid()) { + // Extract the domain part without protocol + const defaultUrl = this.params.defaultUrl; + const domainPart = defaultUrl.replace(/^(https?:\/\/|\/\/)/, ''); + return this.normalizeUrl(`${subdomain}.${domainPart}`); + } + + // Fall back to default URL + return this.normalizeUrl(this.params.defaultUrl); + } + + private endpoint(resource: string): string { + const url = this.joinUrlParts(this._effectiveBaseUrl, resource); + const queryParams = this.params.queryParams; if (!queryParams) { - return endpointUrl; + return url; } + const urlSearchParams = new URLSearchParams(); Object.entries(queryParams).forEach(([key, value]) => urlSearchParams.append(key, value)); - return `${endpointUrl}?${urlSearchParams}`; + + return `${url}?${urlSearchParams}`; } + /** + * Returns the URL for the UFC endpoint. + * Uses the configuration base URL determined by baseUrl, subdomain, or default. + * + * @returns The full UFC endpoint URL + */ ufcEndpoint(): string { return this.endpoint(UFC_ENDPOINT); } + /** + * Returns the URL for the bandit parameters endpoint. + * Uses the configuration base URL determined by baseUrl, subdomain, or default. + * + * @returns The full bandit parameters endpoint URL + */ banditParametersEndpoint(): string { return this.endpoint(BANDIT_ENDPOINT); } + /** + * Returns the URL for the precomputed flags endpoint. + * Uses the configuration base URL determined by baseUrl, subdomain, or default. + * + * @returns The full precomputed flags endpoint URL + */ precomputedFlagsEndpoint(): string { return this.endpoint(PRECOMPUTED_FLAGS_ENDPOINT); } + + /** + * Constructs the event ingestion URL from the SDK token. + * + * IMPORTANT: This method ignores baseUrl and defaultUrl parameters completely. + * It uses ONLY the hostname or subdomain from the SDK token with a fixed event domain. + * + * @returns The event ingestion URL, or null if the SDK token is invalid or doesn't + * contain the necessary information. + */ + eventIngestionEndpoint(): string | null { + if (!this.sdkToken?.isValid()) return null; + + const hostname = this.sdkToken.getEventIngestionHostname(); + const subdomain = this.sdkToken.getSubdomain(); + + if (!hostname && !subdomain) return null; + + // If we have a hostname from the token, use it directly + if (hostname) { + return this.normalizeUrl(this.joinUrlParts(hostname, EVENT_ENDPOINT)); + } + + // Otherwise use subdomain with default event domain + if (subdomain) { + return this.normalizeUrl( + this.joinUrlParts(`${subdomain}.${DEFAULT_EVENT_DOMAIN}`, EVENT_ENDPOINT), + ); + } + + return null; + } } diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 4ef63f1..3cb4fde 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -970,6 +970,87 @@ describe('EppoClient E2E test', () => { }); }); + describe('Contstructed with enhanced SDK Token', () => { + let urlRequests: string[] = []; + beforeEach(() => { + urlRequests = []; + }); + + beforeAll(() => { + global.fetch = jest.fn((url) => { + urlRequests.push(url); + const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); + + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(ufc), + }); + }) as jest.Mock; + }); + + it('uses the default base URL when the API Key is not an enhanced token', async () => { + const client = new EppoClient({ + configurationRequestParameters: { + apiKey: 'basic-token', + pollAfterSuccessfulInitialization: false, + pollAfterFailedInitialization: false, + sdkVersion: '', + sdkName: '', + }, + + flagConfigurationStore: storage, + }); + + await client.fetchFlagConfigurations(); + + expect(urlRequests).toEqual([ + 'https://fscdn.eppo.cloud/api/flag-config/v1/config?apiKey=basic-token&sdkName=&sdkVersion=', + ]); + }); + + it('uses the customer-specific subdomain when provided', async () => { + const client = new EppoClient({ + configurationRequestParameters: { + apiKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', + pollAfterSuccessfulInitialization: false, + pollAfterFailedInitialization: false, + sdkVersion: '', + sdkName: '', + }, + + flagConfigurationStore: storage, + }); + + await client.fetchFlagConfigurations(); + + expect(urlRequests).toHaveLength(1); + expect(urlRequests[0]).toContain( + 'https://experiment.fscdn.eppo.cloud/api/flag-config/v1/config', + ); + }); + + it('prefers a provided baseUrl over encoded subdomain', async () => { + const client = new EppoClient({ + configurationRequestParameters: { + baseUrl: 'http://override.base.url', + apiKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', + pollAfterSuccessfulInitialization: false, + pollAfterFailedInitialization: false, + sdkVersion: '', + sdkName: '', + }, + + flagConfigurationStore: storage, + }); + + await client.fetchFlagConfigurations(); + + expect(urlRequests).toHaveLength(1); + expect(urlRequests[0]).toContain('http://override.base.url/flag-config/v1/config'); + }); + }); + describe('flag overrides', () => { let client: EppoClient; let mockLogger: IAssignmentLogger; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 8d70c4f..e7d9c6d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -56,6 +56,7 @@ import { import { getMD5Hash } from '../obfuscation'; import { OverridePayload, OverrideValidator } from '../override-validator'; import initPoller, { IPoller } from '../poller'; +import SdkTokenDecoder from '../sdk-token-decoder'; import { Attributes, AttributeType, @@ -326,11 +327,12 @@ export default class EppoClient { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; } - // todo: Inject the chain of dependencies below const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams: { apiKey, sdkName, sdkVersion }, + sdkTokenDecoder: new SdkTokenDecoder(apiKey), }); + const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); const configurationRequestor = new ConfigurationRequestor( httpClient, diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts index a288e5b..2c9192e 100644 --- a/src/client/eppo-precomputed-client.ts +++ b/src/client/eppo-precomputed-client.ts @@ -161,7 +161,8 @@ export default class EppoPrecomputedClient { // todo: Inject the chain of dependencies below const apiEndpoints = new ApiEndpoints({ - baseUrl: baseUrl ?? PRECOMPUTED_BASE_URL, + defaultUrl: PRECOMPUTED_BASE_URL, + baseUrl, queryParams: { apiKey, sdkName, sdkVersion }, }); const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); diff --git a/src/configuration-wire/configuration-wire-helper.ts b/src/configuration-wire/configuration-wire-helper.ts index 0a06511..189c9c3 100644 --- a/src/configuration-wire/configuration-wire-helper.ts +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -4,6 +4,7 @@ import FetchHttpClient, { IHttpClient, IUniversalFlagConfigResponse, } from '../http-client'; +import SdkTokenDecoder from '../sdk-token-decoder'; import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; @@ -45,6 +46,7 @@ export class ConfigurationWireHelper { const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams, + sdkTokenDecoder: new SdkTokenDecoder(sdkKey), }); this.httpClient = new FetchHttpClient(apiEndpoints, 5000); diff --git a/src/constants.ts b/src/constants.ts index f7c3b30..b76096e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,8 +9,12 @@ export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; export const BASE_URL = 'https://fscdn.eppo.cloud/api'; export const UFC_ENDPOINT = '/flag-config/v1/config'; export const BANDIT_ENDPOINT = '/flag-config/v1/bandits'; +export const EVENT_ENDPOINT = '/v0/i'; + export const PRECOMPUTED_BASE_URL = 'https://fs-edge-assignment.eppo.cloud'; export const PRECOMPUTED_FLAGS_ENDPOINT = '/assignments'; +export const DEFAULT_EVENT_DOMAIN = 'e.eppo.cloud'; + export const SESSION_ASSIGNMENT_CONFIG_LOADED = 'eppo-session-assignment-config-loaded'; export const NULL_SENTINEL = 'EPPO_NULL'; // number of logging events that may be queued while waiting for initialization diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index f5a601e..668ebc2 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -1,3 +1,4 @@ +import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; import BatchEventProcessor from './batch-event-processor'; @@ -8,7 +9,6 @@ import EventDispatcher from './event-dispatcher'; import NamedEventQueue from './named-event-queue'; import NetworkStatusListener from './network-status-listener'; import NoOpEventDispatcher from './no-op-event-dispatcher'; -import SdkKeyDecoder from './sdk-key-decoder'; export type EventDispatcherConfig = { // The Eppo SDK key @@ -177,8 +177,7 @@ export function newDefaultEventDispatcher( batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, config: Omit = DEFAULT_EVENT_DISPATCHER_CONFIG, ): EventDispatcher { - const sdkKeyDecoder = new SdkKeyDecoder(); - const ingestionUrl = sdkKeyDecoder.decodeEventIngestionUrl(sdkKey); + const ingestionUrl = ApiEndpoints.createEventIngestionUrl(sdkKey); if (!ingestionUrl) { logger.debug( 'Unable to parse Event ingestion URL from SDK key, falling back to no-op event dispatcher', diff --git a/src/events/sdk-key-decoder.spec.ts b/src/events/sdk-key-decoder.spec.ts deleted file mode 100644 index aa29f7b..0000000 --- a/src/events/sdk-key-decoder.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import SdkKeyDecoder from './sdk-key-decoder'; - -describe('SdkKeyDecoder', () => { - const decoder = new SdkKeyDecoder(); - it('should decode the event ingestion hostname from the SDK key', () => { - const hostname = decoder.decodeEventIngestionUrl( - 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', - ); - expect(hostname).toEqual('https://123456.e.testing.eppo.cloud/v0/i'); - }); - - it('should decode strings with non URL-safe characters', () => { - // this is not a really valid ingestion URL, but it's useful for testing the decoder - const invalidUrl = 'eh=12+3456/.e.testing.eppo.cloud'; - const encoded = Buffer.from(invalidUrl).toString('base64url'); - const hostname = decoder.decodeEventIngestionUrl(`zCsQuoHJxVPp895.${encoded}`); - expect(hostname).toEqual('https://12 3456/.e.testing.eppo.cloud/v0/i'); - }); - - it("should return null if the SDK key doesn't contain the event ingestion hostname", () => { - expect(decoder.decodeEventIngestionUrl('zCsQuoHJxVPp895')).toBeNull(); - expect(decoder.decodeEventIngestionUrl('zCsQuoHJxVPp895.xxxxxx')).toBeNull(); - }); -}); diff --git a/src/events/sdk-key-decoder.ts b/src/events/sdk-key-decoder.ts deleted file mode 100644 index 47dd422..0000000 --- a/src/events/sdk-key-decoder.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Base64 } from 'js-base64'; - -const PATH = 'v0/i'; - -export default class SdkKeyDecoder { - /** - * Decodes and returns the event ingestion hostname from the provided Eppo SDK key string. - * If the SDK key doesn't contain the event ingestion hostname, or it's invalid, it returns null. - */ - decodeEventIngestionUrl(sdkKey: string): string | null { - const encodedPayload = sdkKey.split('.')[1]; - if (!encodedPayload) return null; - - const decodedPayload = Base64.decode(encodedPayload); - const params = new URLSearchParams(decodedPayload); - const hostname = params.get('eh'); - if (!hostname) return null; - - const hostAndPath = hostname.endsWith('/') ? `${hostname}${PATH}` : `${hostname}/${PATH}`; - if (!hostAndPath.startsWith('http://') && !hostAndPath.startsWith('https://')) { - // prefix hostname with https scheme if none present - return `https://${hostAndPath}`; - } else { - return hostAndPath; - } - } -} diff --git a/src/http-client.spec.ts b/src/http-client.spec.ts index c73a303..c0ee3be 100644 --- a/src/http-client.spec.ts +++ b/src/http-client.spec.ts @@ -29,12 +29,11 @@ describe('FetchHttpClient', () => { }); (global.fetch as jest.Mock).mockImplementation(() => mockFetchPromise); - const resource = '/data'; - const result = await httpClient.rawGet(apiEndpoints.endpoint(resource)); + const result = await httpClient.rawGet(apiEndpoints.ufcEndpoint()); expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledWith( - `${baseUrl}${resource}?apiKey=12345&sdkVersion=1.0&sdkName=ExampleSDK`, + `${baseUrl}/flag-config/v1/config?apiKey=12345&sdkVersion=1.0&sdkName=ExampleSDK`, { signal: expect.any(AbortSignal) }, ); expect(result).toEqual({ data: 'test' }); @@ -48,8 +47,7 @@ describe('FetchHttpClient', () => { }); (global.fetch as jest.Mock).mockImplementation(() => mockFetchPromise); - const resource = '/data'; - const url = apiEndpoints.endpoint(resource); + const url = apiEndpoints.ufcEndpoint(); await expect(httpClient.rawGet(url)).rejects.toThrow(HttpRequestError); await expect(httpClient.rawGet(url)).rejects.toEqual( new HttpRequestError('Failed to fetch data', 404), @@ -71,8 +69,7 @@ describe('FetchHttpClient', () => { ), ); - const resource = '/data'; - const url = apiEndpoints.endpoint(resource); + const url = apiEndpoints.ufcEndpoint(); const getPromise = httpClient.rawGet(url); // Immediately advance the timers by 10 seconds to simulate the timeout diff --git a/src/sdk-token-decoder.spec.ts b/src/sdk-token-decoder.spec.ts new file mode 100644 index 0000000..2fa0233 --- /dev/null +++ b/src/sdk-token-decoder.spec.ts @@ -0,0 +1,56 @@ +import SdkTokenDecoder from './sdk-token-decoder'; + +describe('EnhancedSdkToken', () => { + it('should extract the event ingestion hostname from the SDK token', () => { + const token = new SdkTokenDecoder('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'); + expect(token.getEventIngestionHostname()).toEqual('123456.e.testing.eppo.cloud'); + }); + + it('should extract the subdomain from the SDK token', () => { + const token = new SdkTokenDecoder( + 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudCZlaD1hYmMxMjMuZXBwby5jbG91ZA==', + ); + expect(token.getSubdomain()).toEqual('experiment'); + expect(token.getEventIngestionHostname()).toEqual('abc123.eppo.cloud'); + }); + + it('should handle tokens with non URL-safe characters', () => { + // Include both eh and cs parameters with special characters + const params = 'eh=12+3456/.e.testing.eppo.cloud&cs=test+subdomain/special'; + const encoded = Buffer.from(params).toString('base64url'); + const token = new SdkTokenDecoder(`zCsQuoHJxVPp895.${encoded}`); + + expect(token.getEventIngestionHostname()).toEqual('12 3456/.e.testing.eppo.cloud'); + expect(token.getSubdomain()).toEqual('test subdomain/special'); + }); + + it('should return null for tokens without the required parameter', () => { + const tokenWithoutEh = new SdkTokenDecoder('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment + expect(tokenWithoutEh.getEventIngestionHostname()).toBeNull(); + expect(tokenWithoutEh.getSubdomain()).toEqual('experiment'); + expect(tokenWithoutEh.isValid()).toBeTruthy(); + + const tokenWithoutCs = new SdkTokenDecoder('zCsQuoHJxVPp895.ZWg9YWJjMTIzLmVwcG8uY2xvdWQ='); // only eh=abc123.eppo.cloud + expect(tokenWithoutCs.getEventIngestionHostname()).toEqual('abc123.eppo.cloud'); + expect(tokenWithoutCs.getSubdomain()).toBeNull(); + expect(tokenWithoutCs.isValid()).toBeTruthy(); + }); + + it('should handle invalid tokens', () => { + const invalidToken = new SdkTokenDecoder('zCsQuoHJxVPp895'); + expect(invalidToken.getEventIngestionHostname()).toBeNull(); + expect(invalidToken.getSubdomain()).toBeNull(); + expect(invalidToken.isValid()).toBeFalsy(); + + const invalidEncodingToken = new SdkTokenDecoder('zCsQuoHJxVPp895.%%%'); + expect(invalidEncodingToken.getEventIngestionHostname()).toBeNull(); + expect(invalidEncodingToken.getSubdomain()).toBeNull(); + expect(invalidEncodingToken.isValid()).toBeFalsy(); + }); + + it('should provide access to the original token string', () => { + const tokenString = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'; + const token = new SdkTokenDecoder(tokenString); + expect(token.getToken()).toEqual(tokenString); + }); +}); diff --git a/src/sdk-token-decoder.ts b/src/sdk-token-decoder.ts new file mode 100644 index 0000000..3f03137 --- /dev/null +++ b/src/sdk-token-decoder.ts @@ -0,0 +1,47 @@ +import { Base64 } from 'js-base64'; + +/** + * Decodes SDK tokens with embedded encoded data. + */ +export default class SdkTokenDecoder { + private readonly decodedParams: URLSearchParams | null; + + constructor(private readonly sdkKey: string) { + try { + const [, payload] = sdkKey.split('.'); + const encodedPayload = payload ?? null; + this.decodedParams = encodedPayload + ? new URLSearchParams(Base64.decode(encodedPayload)) + : null; + } catch { + this.decodedParams = null; + } + } + + private getDecodedValue(key: string): string | null { + return this.decodedParams?.get(key) || null; + } + + getEventIngestionHostname(): string | null { + return this.getDecodedValue('eh'); + } + + getSubdomain(): string | null { + return this.getDecodedValue('cs'); + } + + /** + * Gets the raw SDK Key. + */ + getToken(): string { + return this.sdkKey; + } + + /** + * Checks if the SDK Key had the subdomain or event hostname encoded. + */ + isValid(): boolean { + if (!this.decodedParams) return false; + return !!this.getEventIngestionHostname() || !!this.getSubdomain(); + } +}