From 8701dd4585331f380064b3c0708ac077eaaf3bd6 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 00:27:38 -0600 Subject: [PATCH 01/12] wip --- src/api-endpoint.spec.ts | 124 +++++++++++++++++- src/api-endpoints.ts | 85 ++++++++++-- src/client/eppo-client.ts | 4 +- src/client/eppo-precomputed-client.ts | 3 +- .../configuration-wire-helper.ts | 2 + src/constants.ts | 5 +- src/enhanced-sdk-token.spec.ts | 54 ++++++++ src/enhanced-sdk-token.ts | 73 +++++++++++ src/events/default-event-dispatcher.ts | 5 +- src/events/sdk-key-decoder.spec.ts | 13 +- src/events/sdk-key-decoder.ts | 42 +++--- 11 files changed, 364 insertions(+), 46 deletions(-) create mode 100644 src/enhanced-sdk-token.spec.ts create mode 100644 src/enhanced-sdk-token.ts diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index d77e7b2..76e6de3 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -1,10 +1,11 @@ import ApiEndpoints from './api-endpoints'; import { BASE_URL as DEFAULT_BASE_URL } from './constants'; +import EnhancedSdkToken from './enhanced-sdk-token'; describe('ApiEndpoints', () => { it('should append query parameters to the URL', () => { const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://api.example.com', + baseUrl: 'https://api.example.com', queryParams: { apiKey: '12345', sdkVersion: 'foobar', @@ -12,10 +13,10 @@ describe('ApiEndpoints', () => { }, }); expect(apiEndpoints.endpoint('/data').toString()).toEqual( - 'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', + 'https://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', + 'https://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', ); }); @@ -42,4 +43,121 @@ describe('ApiEndpoints', () => { `${DEFAULT_BASE_URL}/flag-config/v1/config`, ); }); + + describe('Base URL determination', () => { + it('should use custom baseUrl when provided', () => { + const customBaseUrl = 'https://custom-domain.com'; + const endpoints = new ApiEndpoints({ baseUrl: customBaseUrl }); + expect(endpoints.endpoint('')).toContain(customBaseUrl); + }); + + it('should use subdomain from SDK token when valid', () => { + // This token has cs=test-subdomain + const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; + const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); + expect(endpoints.getEffectiveBaseUrl()).toBe('https://test-subdomain.fscdn.eppo.cloud/api'); + }); + + it('should prefer custom baseUrl over SDK token subdomain', () => { + const customBaseUrl = 'https://custom-domain.com'; + // This token has cs=test-subdomain + const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; + const endpoints = new ApiEndpoints({ + baseUrl: customBaseUrl, + sdkToken: new EnhancedSdkToken(sdkToken), + }); + expect(endpoints.getEffectiveBaseUrl()).toBe(customBaseUrl); + }); + + it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => { + // This token has no cs parameter + const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU='; + const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); + expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL); + }); + + it('should fallback to DEFAULT_BASE_URL when SDK token is invalid', () => { + const invalidToken = new EnhancedSdkToken('invalid-token'); + const endpoints = new ApiEndpoints({ sdkToken: invalidToken }); + expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL); + }); + }); + + describe('Endpoint URL construction', () => { + it('should use effective base URL for UFC endpoint', () => { + // This token has cs=test-subdomain + const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; + const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); + + expect(endpoints.ufcEndpoint()).toContain( + 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/config', + ); + }); + + it('should use effective base URL for bandit parameters endpoint', () => { + // This token has cs=test-subdomain + const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; + const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); + + expect(endpoints.banditParametersEndpoint()).toContain( + 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/bandits', + ); + }); + + it('should use the sub-domain and default base URL for precomputed flags endpoint', () => { + // This token has cs=test-subdomain + const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; + const endpoints = new ApiEndpoints({ + sdkToken: new EnhancedSdkToken(sdkToken), + defaultUrl: 'default.eppo.cloud', + }); + + expect(endpoints.precomputedFlagsEndpoint()).toContain('default.eppo.cloud'); + expect(endpoints.precomputedFlagsEndpoint()).toContain('test-subdomain'); + }); + + it('should handle slash management between base URL and resource', () => { + const baseUrlWithSlash = 'https://domain.com/'; + const baseUrlWithoutSlash = 'https://domain.com'; + const resourceWithSlash = '/resource'; + const resourceWithoutSlash = 'resource'; + + const endpoints1 = new ApiEndpoints({ baseUrl: baseUrlWithSlash }); + const endpoints2 = new ApiEndpoints({ baseUrl: baseUrlWithoutSlash }); + + // Test all combinations to ensure we avoid double slashes and always have one slash + expect(endpoints1.endpoint(resourceWithSlash)).toBe('https://domain.com/resource'); + expect(endpoints1.endpoint(resourceWithoutSlash)).toBe('https://domain.com/resource'); + expect(endpoints2.endpoint(resourceWithSlash)).toBe('https://domain.com/resource'); + expect(endpoints2.endpoint(resourceWithoutSlash)).toBe('https://domain.com/resource'); + }); + }); + + describe('Query parameter handling', () => { + it('should append query parameters to endpoint URLs', () => { + const queryParams = { apiKey: 'test-key', sdkName: 'js-sdk', sdkVersion: '1.0.0' }; + const endpoints = new ApiEndpoints({ queryParams }); + + const url = endpoints.ufcEndpoint(); + + expect(url).toContain('?'); + expect(url).toContain('apiKey=test-key'); + expect(url).toContain('sdkName=js-sdk'); + expect(url).toContain('sdkVersion=1.0.0'); + }); + + it('should properly encode query parameters with special characters', () => { + const queryParams = { + apiKey: 'test-key', + sdkName: 'value with spaces', + sdkVersion: 'a+b=c&d', + }; + const endpoints = new ApiEndpoints({ queryParams }); + + const url = endpoints.ufcEndpoint(); + + expect(url).toContain('sdkName=value+with+spaces'); + expect(url).toContain('sdkVersion=a%2Bb%3Dc%26d'); + }); + }); }); diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index a7297f6..93d4900 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -1,42 +1,109 @@ -import { - BASE_URL as DEFAULT_BASE_URL, - UFC_ENDPOINT, - BANDIT_ENDPOINT, - PRECOMPUTED_FLAGS_ENDPOINT, -} from './constants'; +import { BANDIT_ENDPOINT, BASE_URL, PRECOMPUTED_FLAGS_ENDPOINT, UFC_ENDPOINT } from './constants'; +import EnhancedSdkToken from './enhanced-sdk-token'; import { IQueryParams, IQueryParamsWithSubject } from './http-client'; interface IApiEndpointsParams { queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; + defaultUrl: string; + sdkToken?: EnhancedSdkToken; } /** Utility class for constructing an Eppo API endpoint URL given a provided baseUrl and query parameters */ export default class ApiEndpoints { - constructor(private readonly params: IApiEndpointsParams) { - this.params.baseUrl = params.baseUrl ?? DEFAULT_BASE_URL; + private readonly sdkToken: EnhancedSdkToken | null; + private readonly _effectiveBaseUrl: string; + private readonly params: IApiEndpointsParams; + + constructor(params: Partial) { + this.params = Object.assign({}, { defaultUrl: BASE_URL }, params); + this.sdkToken = params.sdkToken ?? null; + + // this.params.baseUrl = + // params.baseUrl && params.baseUrl !== DEFAULT_BASE_URL ? params.baseUrl : DEFAULT_URL; + + // Set the effective base URL. + this._effectiveBaseUrl = this.determineBaseUrl(); + } + + /** + * Determine the effective base URL based on the constructor parameters: + * 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 + */ + 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.params.baseUrl; + } + + // If there's an enhanced SDK token with a subdomain, it will be prepended in the buildUrl method. + const subdomain = this.sdkToken?.getSubdomain(); + return this.buildUrl(this.params.defaultUrl, subdomain); + } + + private buildUrl(domain: string, subdomain?: string | null) { + const protocol = ApiEndpoints.URL_PROTOCOLS.find((v) => domain.startsWith(v)) ?? 'https://'; + + const base = this.stripProtocol(domain); + return subdomain ? `${protocol}${subdomain}.${base}` : `${protocol}${base}`; + } + + /** + * Returns the base URL being used for the UFC and bandit endpoints + */ + getEffectiveBaseUrl(): string { + return this._effectiveBaseUrl; } + /** + * Creates an endpoint URL with the specified resource path and query parameters + */ endpoint(resource: string): string { - const endpointUrl = `${this.params.baseUrl}${resource}`; + const baseUrl = this._effectiveBaseUrl; + + // Ensure baseUrl and resource join correctly with only one slash + const base = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + const path = resource.startsWith('/') ? resource.substring(1) : resource; + const endpointUrl = `${base}/${path}`; + const queryParams = this.params.queryParams; if (!queryParams) { return endpointUrl; } + const urlSearchParams = new URLSearchParams(); Object.entries(queryParams).forEach(([key, value]) => urlSearchParams.append(key, value)); + return `${endpointUrl}?${urlSearchParams}`; } + /** + * Returns the URL for the UFC endpoint + */ ufcEndpoint(): string { return this.endpoint(UFC_ENDPOINT); } + /** + * Returns the URL for the bandit parameters endpoint + */ banditParametersEndpoint(): string { return this.endpoint(BANDIT_ENDPOINT); } + /** + * Returns the URL for the precomputed flags endpoint + */ precomputedFlagsEndpoint(): string { return this.endpoint(PRECOMPUTED_FLAGS_ENDPOINT); } + + private stripProtocol(url: string) { + return ApiEndpoints.URL_PROTOCOLS.reduce((prev, cur) => { + return prev.replace(cur, ''); + }, url); + } + public static readonly URL_PROTOCOLS = ['http://', 'https://', '//']; } diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 8d70c4f..b989d6c 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -30,6 +30,7 @@ import { DEFAULT_REQUEST_TIMEOUT_MS, } from '../constants'; import { decodeFlag } from '../decoding'; +import EnhancedSdkToken from '../enhanced-sdk-token'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; import { BoundedEventQueue } from '../events/bounded-event-queue'; @@ -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 }, + sdkToken: new EnhancedSdkToken(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..af03b65 100644 --- a/src/configuration-wire/configuration-wire-helper.ts +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -1,4 +1,5 @@ import ApiEndpoints from '../api-endpoints'; +import EnhancedSdkToken from '../enhanced-sdk-token'; import FetchHttpClient, { IBanditParametersResponse, IHttpClient, @@ -45,6 +46,7 @@ export class ConfigurationWireHelper { const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams, + sdkToken: new EnhancedSdkToken(sdkKey), }); this.httpClient = new FetchHttpClient(apiEndpoints, 5000); diff --git a/src/constants.ts b/src/constants.ts index f7c3b30..7ccf1f6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,11 +6,14 @@ export const DEFAULT_POLL_INTERVAL_MS = 30000; export const POLL_JITTER_PCT = 0.1; export const DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES = 1; export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; -export const BASE_URL = 'https://fscdn.eppo.cloud/api'; +export const DEFAULT_URL = 'fscdn.eppo.cloud/api'; +export const BASE_URL = 'https://' + DEFAULT_URL; export const UFC_ENDPOINT = '/flag-config/v1/config'; export const BANDIT_ENDPOINT = '/flag-config/v1/bandits'; 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/enhanced-sdk-token.spec.ts b/src/enhanced-sdk-token.spec.ts new file mode 100644 index 0000000..35d35bd --- /dev/null +++ b/src/enhanced-sdk-token.spec.ts @@ -0,0 +1,54 @@ +import EnhancedSdkToken from './enhanced-sdk-token'; + +describe('EnhancedSdkToken', () => { + it('should extract the event ingestion hostname from the SDK token', () => { + const token = new EnhancedSdkToken('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'); + expect(token.getEventIngestionHostname()).toEqual('123456.e.testing.eppo.cloud'); + }); + + it('should extract the subdomain from the SDK token', () => { + const token = new EnhancedSdkToken( + '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 EnhancedSdkToken(`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 EnhancedSdkToken('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment + expect(tokenWithoutEh.getEventIngestionHostname()).toBeNull(); + expect(tokenWithoutEh.getSubdomain()).toEqual('experiment'); + + const tokenWithoutCs = new EnhancedSdkToken('zCsQuoHJxVPp895.ZWg9YWJjMTIzLmVwcG8uY2xvdWQ='); // only eh=abc123.eppo.cloud + expect(tokenWithoutCs.getEventIngestionHostname()).toEqual('abc123.eppo.cloud'); + expect(tokenWithoutCs.getSubdomain()).toBeNull(); + }); + + it('should handle invalid tokens', () => { + const invalidToken = new EnhancedSdkToken('zCsQuoHJxVPp895'); + expect(invalidToken.getEventIngestionHostname()).toBeNull(); + expect(invalidToken.getSubdomain()).toBeNull(); + expect(invalidToken.isValid()).toBeFalsy(); + + const invalidEncodingToken = new EnhancedSdkToken('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 EnhancedSdkToken(tokenString); + expect(token.getToken()).toEqual(tokenString); + }); +}); diff --git a/src/enhanced-sdk-token.ts b/src/enhanced-sdk-token.ts new file mode 100644 index 0000000..663741e --- /dev/null +++ b/src/enhanced-sdk-token.ts @@ -0,0 +1,73 @@ +import { Base64 } from 'js-base64'; + +/** + * Represents an enhanced SDK token that can extract various fields from the token. + */ +export default class EnhancedSdkToken { + private readonly encodedPayload: string | null; + private readonly decodedParams: URLSearchParams | null; + + /** + * Creates a new instance of EnhancedSdkToken. + * @param sdkToken The SDK token string to parse + */ + constructor(private readonly sdkToken: string) { + const parts = sdkToken.split('.'); + this.encodedPayload = parts.length > 1 ? parts[1] : null; + + if (this.encodedPayload) { + try { + const decodedPayload = Base64.decode(this.encodedPayload); + this.decodedParams = new URLSearchParams(decodedPayload); + } catch (e) { + this.decodedParams = null; + } + } else { + this.decodedParams = null; + } + } + + /** + * Gets the value for a specific key from the decoded token. + * @param key The key to retrieve from the decoded parameters + * @returns The value for the key, or null if not found or if token is invalid + */ + private getDecodedValue(key: string): string | null { + return this.decodedParams?.get(key) || null; + } + + /** + * Gets the event ingestion hostname from the token. + * @returns The event ingestion hostname, or null if not present + */ + getEventIngestionHostname(): string | null { + return this.getDecodedValue('eh'); + } + + /** + * Gets the subdomain from the token. + * @returns The subdomain, or null if not present + */ + getSubdomain(): string | null { + return this.getDecodedValue('cs'); + } + + /** + * Gets the raw token string. + * @returns The original SDK token string + */ + getToken(): string { + return this.sdkToken; + } + + /** + * Checks if the token is valid (has encoded payload that can be decoded). + * @returns true if the token is valid, false otherwise + */ + isValid(): boolean { + return ( + this.decodedParams !== null && + (this.getSubdomain() !== null || this.getEventIngestionHostname() !== null) + ); + } +} diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index f5a601e..9b5bafd 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -8,7 +8,7 @@ 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'; +import { buildIngestionUrl } 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 = buildIngestionUrl(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 index aa29f7b..5fe39aa 100644 --- a/src/events/sdk-key-decoder.spec.ts +++ b/src/events/sdk-key-decoder.spec.ts @@ -1,11 +1,8 @@ -import SdkKeyDecoder from './sdk-key-decoder'; +import { buildIngestionUrl } 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', - ); + const hostname = buildIngestionUrl('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'); expect(hostname).toEqual('https://123456.e.testing.eppo.cloud/v0/i'); }); @@ -13,12 +10,12 @@ describe('SdkKeyDecoder', () => { // 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}`); + const hostname = buildIngestionUrl(`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(); + expect(buildIngestionUrl('zCsQuoHJxVPp895')).toBeNull(); + expect(buildIngestionUrl('zCsQuoHJxVPp895.xxxxxx')).toBeNull(); }); }); diff --git a/src/events/sdk-key-decoder.ts b/src/events/sdk-key-decoder.ts index 47dd422..de58a28 100644 --- a/src/events/sdk-key-decoder.ts +++ b/src/events/sdk-key-decoder.ts @@ -1,27 +1,29 @@ -import { Base64 } from 'js-base64'; +import ApiEndpoints from '../api-endpoints'; +import { DEFAULT_EVENT_DOMAIN } from '../constants'; +import EnhancedSdkToken from '../enhanced-sdk-token'; 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; +export function buildIngestionUrl(sdkKey: string): string | null { + const sdkToken = new EnhancedSdkToken(sdkKey); + if (!sdkToken.isValid()) return null; - const decodedPayload = Base64.decode(encodedPayload); - const params = new URLSearchParams(decodedPayload); - const hostname = params.get('eh'); - if (!hostname) return null; + const encodedPayload = sdkKey.split('.')[1]; + if (!encodedPayload) 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; - } + const hostname = sdkToken.getEventIngestionHostname(); + const subdomain = sdkToken.getSubdomain(); + + const effectiveHost = subdomain ? `https://${subdomain}.${DEFAULT_EVENT_DOMAIN}` : hostname; + if (!effectiveHost) return null; + + const hostAndPath = effectiveHost.endsWith('/') + ? `${effectiveHost}${PATH}` + : `${effectiveHost}/${PATH}`; + if (!ApiEndpoints.URL_PROTOCOLS.find((p) => hostAndPath.startsWith(p))) { + // prefix hostname with https scheme if none present + return `https://${hostAndPath}`; + } else { + return hostAndPath; } } From 23f25a1e18dc2e950a857d37701c392dd4d1feba Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 11:25:45 -0600 Subject: [PATCH 02/12] move event URL into api endpoints --- src/api-endpoint.spec.ts | 157 +++++++++++++++++++++++-- src/api-endpoints.ts | 117 +++++++++++------- src/events/default-event-dispatcher.ts | 6 +- src/events/sdk-key-decoder.spec.ts | 21 ---- src/events/sdk-key-decoder.ts | 29 ----- 5 files changed, 226 insertions(+), 104 deletions(-) delete mode 100644 src/events/sdk-key-decoder.spec.ts delete mode 100644 src/events/sdk-key-decoder.ts diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index 76e6de3..2c3f178 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -1,11 +1,13 @@ +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 EnhancedSdkToken from './enhanced-sdk-token'; describe('ApiEndpoints', () => { it('should append query parameters to the URL', () => { const apiEndpoints = new ApiEndpoints({ - baseUrl: 'https://api.example.com', + baseUrl: 'http://api.example.com', queryParams: { apiKey: '12345', sdkVersion: 'foobar', @@ -13,10 +15,10 @@ describe('ApiEndpoints', () => { }, }); expect(apiEndpoints.endpoint('/data').toString()).toEqual( - 'https://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', + 'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', ); expect(apiEndpoints.ufcEndpoint().toString()).toEqual( - 'https://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', + 'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK', ); }); @@ -55,7 +57,7 @@ describe('ApiEndpoints', () => { // This token has cs=test-subdomain const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); - expect(endpoints.getEffectiveBaseUrl()).toBe('https://test-subdomain.fscdn.eppo.cloud/api'); + expect(endpoints.endpoint('/data')).toBe('https://test-subdomain.fscdn.eppo.cloud/api/data'); }); it('should prefer custom baseUrl over SDK token subdomain', () => { @@ -66,20 +68,21 @@ describe('ApiEndpoints', () => { baseUrl: customBaseUrl, sdkToken: new EnhancedSdkToken(sdkToken), }); - expect(endpoints.getEffectiveBaseUrl()).toBe(customBaseUrl); + + expect(endpoints.endpoint('')).toContain(customBaseUrl); }); it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => { // This token has no cs parameter const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); - expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL); + expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy(); }); it('should fallback to DEFAULT_BASE_URL when SDK token is invalid', () => { const invalidToken = new EnhancedSdkToken('invalid-token'); const endpoints = new ApiEndpoints({ sdkToken: invalidToken }); - expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL); + expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy(); }); }); @@ -133,6 +136,37 @@ describe('ApiEndpoints', () => { }); }); + describe('Event Url generation', () => { + const hostnameToken = new EnhancedSdkToken( + 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', + ); + const mockedToken = td.object(); + beforeAll(() => { + td.when(mockedToken.isValid()).thenReturn(true); + }); + + it('should decode the event ingestion hostname from the SDK key', () => { + const endpoints = new ApiEndpoints({ sdkToken: hostnameToken }); + const hostname = endpoints.eventIngestionEndpoint(); + 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 + td.when(mockedToken.getEventIngestionHostname()).thenReturn('12 3456/.e.testing.eppo.cloud'); + const endpoints = new ApiEndpoints({ sdkToken: mockedToken }); + const hostname = endpoints.eventIngestionEndpoint(); + 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", () => { + td.when(mockedToken.isValid()).thenReturn(false); + const endpoints = new ApiEndpoints({ sdkToken: mockedToken }); + const hostname = endpoints.eventIngestionEndpoint(); + expect(hostname).toBeNull(); + }); + }); + describe('Query parameter handling', () => { it('should append query parameters to endpoint URLs', () => { const queryParams = { apiKey: 'test-key', sdkName: 'js-sdk', sdkVersion: '1.0.0' }; @@ -161,3 +195,110 @@ describe('ApiEndpoints', () => { }); }); }); + +describe('ApiEndpoints - Additional Tests', () => { + describe('URL normalization', () => { + it('should preserve different protocol types', () => { + // We can test this indirectly through the endpoint method + const httpEndpoints = new ApiEndpoints({ baseUrl: 'http://example.com' }); + const httpsEndpoints = new ApiEndpoints({ baseUrl: 'https://example.com' }); + const protocolRelativeEndpoints = new ApiEndpoints({ baseUrl: '//example.com' }); + + expect(httpEndpoints.endpoint('test')).toEqual('http://example.com/test'); + expect(httpsEndpoints.endpoint('test')).toEqual('https://example.com/test'); + expect(protocolRelativeEndpoints.endpoint('test')).toEqual('//example.com/test'); + }); + + it('should add https:// to URLs without protocols', () => { + const endpoints = new ApiEndpoints({ baseUrl: 'example.com' }); + expect(endpoints.endpoint('test')).toEqual('https://example.com/test'); + }); + + it('should handle multiple slashes', () => { + const endpoints = new ApiEndpoints({ baseUrl: 'example.com/' }); + expect(endpoints.endpoint('/test')).toEqual('https://example.com/test'); + }); + }); + + describe('Subdomain handling', () => { + it('should correctly integrate subdomain with base URLs containing paths', () => { + const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain + const endpoints = new ApiEndpoints({ + sdkToken, + defaultUrl: 'example.com/api/v2', + }); + + expect(endpoints.endpoint('')).toContain('https://test-subdomain.example.com/api/v2'); + }); + + it('should handle subdomains with special characters', () => { + // Encode a token with cs=test-sub.domain-special + const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWIuZG9tYWluLXNwZWNpYWw='); + const endpoints = new ApiEndpoints({ sdkToken }); + + // The implementation should handle this correctly, but this is what we'd expect + expect(endpoints.endpoint('')).toContain('test-sub.domain-special'); + }); + }); + + describe('Event ingestion endpoint', () => { + it('should use subdomain with DEFAULT_EVENT_DOMAIN when hostname is not available', () => { + // Create a mock token with only a subdomain + const mockToken = { + isValid: () => true, + getEventIngestionHostname: () => null, + getSubdomain: () => 'test-subdomain', + } as EnhancedSdkToken; + + const endpoints = new ApiEndpoints({ sdkToken: mockToken }); + expect(endpoints.eventIngestionEndpoint()).toEqual( + `https://test-subdomain.${DEFAULT_EVENT_DOMAIN}/v0/i`, + ); + }); + + it('should prioritize hostname over subdomain if both are available', () => { + // Create a mock token with both hostname and subdomain + const mockToken = { + isValid: () => true, + getEventIngestionHostname: () => 'event-host.example.com', + getSubdomain: () => 'test-subdomain', + } as EnhancedSdkToken; + + const endpoints = new ApiEndpoints({ sdkToken: mockToken }); + expect(endpoints.eventIngestionEndpoint()).toEqual('https://event-host.example.com/v0/i'); + }); + + it('should return null when token is valid but no hostname or subdomain is available', () => { + // Create a mock token with neither hostname nor subdomain + const mockToken = { + isValid: () => true, + getEventIngestionHostname: () => null, + getSubdomain: () => null, + } as EnhancedSdkToken; + + const endpoints = new ApiEndpoints({ sdkToken: mockToken }); + expect(endpoints.eventIngestionEndpoint()).toBeNull(); + }); + }); + + describe('Edge cases and error handling', () => { + it('should handle extremely long subdomains', () => { + const longSubdomain = 'a'.repeat(100); + const mockToken = { + isValid: () => true, + getSubdomain: () => longSubdomain, + } as EnhancedSdkToken; + + const endpoints = new ApiEndpoints({ sdkToken: mockToken }); + expect(endpoints.endpoint('')).toContain(longSubdomain); + }); + + it('should handle unusual base URL formats', () => { + const endpoints = new ApiEndpoints({ + baseUrl: 'https://@:example.com:8080/path?query=value#fragment', + }); + // The exact handling will depend on implementation details, but it shouldn't throw + expect(() => endpoints.endpoint('test')).not.toThrow(); + }); + }); +}); diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index 93d4900..c046e12 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -1,7 +1,15 @@ -import { BANDIT_ENDPOINT, BASE_URL, PRECOMPUTED_FLAGS_ENDPOINT, UFC_ENDPOINT } from './constants'; +import { + BANDIT_ENDPOINT, + BASE_URL, + DEFAULT_EVENT_DOMAIN, + PRECOMPUTED_FLAGS_ENDPOINT, + UFC_ENDPOINT, +} from './constants'; import EnhancedSdkToken from './enhanced-sdk-token'; import { IQueryParams, IQueryParamsWithSubject } from './http-client'; +const EVENT_ENDPOINT = 'v0/i'; + interface IApiEndpointsParams { queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; @@ -9,7 +17,9 @@ interface IApiEndpointsParams { sdkToken?: EnhancedSdkToken; } -/** Utility class for constructing an Eppo API endpoint URL given a provided baseUrl and query parameters */ +/** + * Utility class for constructing Eppo API endpoint URLs + */ export default class ApiEndpoints { private readonly sdkToken: EnhancedSdkToken | null; private readonly _effectiveBaseUrl: string; @@ -18,12 +28,20 @@ export default class ApiEndpoints { constructor(params: Partial) { this.params = Object.assign({}, { defaultUrl: BASE_URL }, params); this.sdkToken = params.sdkToken ?? null; + this._effectiveBaseUrl = this.determineBaseUrl(); + } - // this.params.baseUrl = - // params.baseUrl && params.baseUrl !== DEFAULT_BASE_URL ? params.baseUrl : DEFAULT_URL; + /** + * Normalizes a URL by ensuring proper protocol and removing trailing slashes + */ + private normalizeUrl(url: string, protocol = 'https://'): string { + const protocolMatch = url.match(/^(https?:\/\/|\/\/)/i); - // Set the effective base URL. - this._effectiveBaseUrl = this.determineBaseUrl(); + if (protocolMatch) { + return url; + } else { + return `${protocol}${url}`; + } } /** @@ -32,78 +50,89 @@ export default class ApiEndpoints { * 2. If the api key contains an encoded customer-specific subdomain, use it with DEFAULT_DOMAIN * 3. Otherwise, fall back to DEFAULT_BASE_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('/'); + } + + /** + * Determine the effective base URL based on the constructor parameters + */ 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.params.baseUrl; + return this.normalizeUrl(this.params.baseUrl); } - // If there's an enhanced SDK token with a subdomain, it will be prepended in the buildUrl method. + // If there's a valid SDK token with a subdomain, use it const subdomain = this.sdkToken?.getSubdomain(); - return this.buildUrl(this.params.defaultUrl, subdomain); - } - - private buildUrl(domain: string, subdomain?: string | null) { - const protocol = ApiEndpoints.URL_PROTOCOLS.find((v) => domain.startsWith(v)) ?? 'https://'; - - const base = this.stripProtocol(domain); - return subdomain ? `${protocol}${subdomain}.${base}` : `${protocol}${base}`; - } + 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}`); + } - /** - * Returns the base URL being used for the UFC and bandit endpoints - */ - getEffectiveBaseUrl(): string { - return this._effectiveBaseUrl; + // Fall back to default URL + return this.normalizeUrl(this.params.defaultUrl); } /** * Creates an endpoint URL with the specified resource path and query parameters */ endpoint(resource: string): string { - const baseUrl = this._effectiveBaseUrl; - - // Ensure baseUrl and resource join correctly with only one slash - const base = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; - const path = resource.startsWith('/') ? resource.substring(1) : resource; - const endpointUrl = `${base}/${path}`; + 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 - */ ufcEndpoint(): string { return this.endpoint(UFC_ENDPOINT); } - /** - * Returns the URL for the bandit parameters endpoint - */ banditParametersEndpoint(): string { return this.endpoint(BANDIT_ENDPOINT); } - /** - * Returns the URL for the precomputed flags endpoint - */ precomputedFlagsEndpoint(): string { return this.endpoint(PRECOMPUTED_FLAGS_ENDPOINT); } - private stripProtocol(url: string) { - return ApiEndpoints.URL_PROTOCOLS.reduce((prev, cur) => { - return prev.replace(cur, ''); - }, url); + 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; } - public static readonly URL_PROTOCOLS = ['http://', 'https://', '//']; } diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index 9b5bafd..a9547d9 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -1,4 +1,6 @@ +import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; +import EnhancedSdkToken from '../enhanced-sdk-token'; import BatchEventProcessor from './batch-event-processor'; import BatchRetryManager from './batch-retry-manager'; @@ -8,7 +10,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 { buildIngestionUrl } from './sdk-key-decoder'; export type EventDispatcherConfig = { // The Eppo SDK key @@ -177,7 +178,8 @@ export function newDefaultEventDispatcher( batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, config: Omit = DEFAULT_EVENT_DISPATCHER_CONFIG, ): EventDispatcher { - const ingestionUrl = buildIngestionUrl(sdkKey); + const apiEndpointsHelper = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkKey) }); + const ingestionUrl = apiEndpointsHelper.eventIngestionEndpoint(); 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 5fe39aa..0000000 --- a/src/events/sdk-key-decoder.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildIngestionUrl } from './sdk-key-decoder'; - -describe('SdkKeyDecoder', () => { - it('should decode the event ingestion hostname from the SDK key', () => { - const hostname = buildIngestionUrl('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 = buildIngestionUrl(`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(buildIngestionUrl('zCsQuoHJxVPp895')).toBeNull(); - expect(buildIngestionUrl('zCsQuoHJxVPp895.xxxxxx')).toBeNull(); - }); -}); diff --git a/src/events/sdk-key-decoder.ts b/src/events/sdk-key-decoder.ts deleted file mode 100644 index de58a28..0000000 --- a/src/events/sdk-key-decoder.ts +++ /dev/null @@ -1,29 +0,0 @@ -import ApiEndpoints from '../api-endpoints'; -import { DEFAULT_EVENT_DOMAIN } from '../constants'; -import EnhancedSdkToken from '../enhanced-sdk-token'; - -const PATH = 'v0/i'; - -export function buildIngestionUrl(sdkKey: string): string | null { - const sdkToken = new EnhancedSdkToken(sdkKey); - if (!sdkToken.isValid()) return null; - - const encodedPayload = sdkKey.split('.')[1]; - if (!encodedPayload) return null; - - const hostname = sdkToken.getEventIngestionHostname(); - const subdomain = sdkToken.getSubdomain(); - - const effectiveHost = subdomain ? `https://${subdomain}.${DEFAULT_EVENT_DOMAIN}` : hostname; - if (!effectiveHost) return null; - - const hostAndPath = effectiveHost.endsWith('/') - ? `${effectiveHost}${PATH}` - : `${effectiveHost}/${PATH}`; - if (!ApiEndpoints.URL_PROTOCOLS.find((p) => hostAndPath.startsWith(p))) { - // prefix hostname with https scheme if none present - return `https://${hostAndPath}`; - } else { - return hostAndPath; - } -} From 3784ad6b40ed48947765aebac0f367ed438353c1 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 12:59:40 -0600 Subject: [PATCH 03/12] refactor constants --- src/api-endpoints.ts | 3 +-- src/constants.ts | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index c046e12..5bfc6b6 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -2,14 +2,13 @@ import { BANDIT_ENDPOINT, BASE_URL, DEFAULT_EVENT_DOMAIN, + EVENT_ENDPOINT, PRECOMPUTED_FLAGS_ENDPOINT, UFC_ENDPOINT, } from './constants'; import EnhancedSdkToken from './enhanced-sdk-token'; import { IQueryParams, IQueryParamsWithSubject } from './http-client'; -const EVENT_ENDPOINT = 'v0/i'; - interface IApiEndpointsParams { queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; diff --git a/src/constants.ts b/src/constants.ts index 7ccf1f6..95c22ee 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,10 +6,11 @@ export const DEFAULT_POLL_INTERVAL_MS = 30000; export const POLL_JITTER_PCT = 0.1; export const DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES = 1; export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; -export const DEFAULT_URL = 'fscdn.eppo.cloud/api'; -export const BASE_URL = 'https://' + DEFAULT_URL; +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'; From de5816468dd6646182cda30844798582e382aa59 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 13:16:09 -0600 Subject: [PATCH 04/12] test client using subdomain endpoint --- src/client/eppo-client.spec.ts | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) 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; From fc55a0cebd30080774b345665e4d21c162ca6f91 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 13:31:39 -0600 Subject: [PATCH 05/12] polish tests --- src/api-endpoint.spec.ts | 43 +++++++--------------------------- src/api-endpoints.ts | 3 +-- src/enhanced-sdk-token.spec.ts | 2 ++ src/enhanced-sdk-token.ts | 31 +++++------------------- 4 files changed, 18 insertions(+), 61 deletions(-) diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index 2c3f178..121027b 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -54,7 +54,7 @@ describe('ApiEndpoints', () => { }); it('should use subdomain from SDK token when valid', () => { - // This token has cs=test-subdomain + // cs=test-subdomain const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); expect(endpoints.endpoint('/data')).toBe('https://test-subdomain.fscdn.eppo.cloud/api/data'); @@ -62,7 +62,7 @@ describe('ApiEndpoints', () => { it('should prefer custom baseUrl over SDK token subdomain', () => { const customBaseUrl = 'https://custom-domain.com'; - // This token has cs=test-subdomain + // cs=test-subdomain const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; const endpoints = new ApiEndpoints({ baseUrl: customBaseUrl, @@ -73,7 +73,7 @@ describe('ApiEndpoints', () => { }); it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => { - // This token has no cs parameter + // eh=event-hostname const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy(); @@ -88,7 +88,7 @@ describe('ApiEndpoints', () => { describe('Endpoint URL construction', () => { it('should use effective base URL for UFC endpoint', () => { - // This token has cs=test-subdomain + // cs=test-subdomain const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); @@ -98,7 +98,7 @@ describe('ApiEndpoints', () => { }); it('should use effective base URL for bandit parameters endpoint', () => { - // This token has cs=test-subdomain + // cs=test-subdomain const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); @@ -107,8 +107,8 @@ describe('ApiEndpoints', () => { ); }); - it('should use the sub-domain and default base URL for precomputed flags endpoint', () => { - // This token has cs=test-subdomain + it('should use the subdomain and default base URL for precomputed flags endpoint', () => { + // cs=test-subdomain const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken), @@ -119,7 +119,7 @@ describe('ApiEndpoints', () => { expect(endpoints.precomputedFlagsEndpoint()).toContain('test-subdomain'); }); - it('should handle slash management between base URL and resource', () => { + it('should have exactly one slash between base URL and resource', () => { const baseUrlWithSlash = 'https://domain.com/'; const baseUrlWithoutSlash = 'https://domain.com'; const resourceWithSlash = '/resource'; @@ -199,7 +199,6 @@ describe('ApiEndpoints', () => { describe('ApiEndpoints - Additional Tests', () => { describe('URL normalization', () => { it('should preserve different protocol types', () => { - // We can test this indirectly through the endpoint method const httpEndpoints = new ApiEndpoints({ baseUrl: 'http://example.com' }); const httpsEndpoints = new ApiEndpoints({ baseUrl: 'https://example.com' }); const protocolRelativeEndpoints = new ApiEndpoints({ baseUrl: '//example.com' }); @@ -232,11 +231,10 @@ describe('ApiEndpoints - Additional Tests', () => { }); it('should handle subdomains with special characters', () => { - // Encode a token with cs=test-sub.domain-special + // Token with cs=test-sub.domain-special encoded const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWIuZG9tYWluLXNwZWNpYWw='); const endpoints = new ApiEndpoints({ sdkToken }); - // The implementation should handle this correctly, but this is what we'd expect expect(endpoints.endpoint('')).toContain('test-sub.domain-special'); }); }); @@ -257,7 +255,6 @@ describe('ApiEndpoints - Additional Tests', () => { }); it('should prioritize hostname over subdomain if both are available', () => { - // Create a mock token with both hostname and subdomain const mockToken = { isValid: () => true, getEventIngestionHostname: () => 'event-host.example.com', @@ -269,7 +266,6 @@ describe('ApiEndpoints - Additional Tests', () => { }); it('should return null when token is valid but no hostname or subdomain is available', () => { - // Create a mock token with neither hostname nor subdomain const mockToken = { isValid: () => true, getEventIngestionHostname: () => null, @@ -280,25 +276,4 @@ describe('ApiEndpoints - Additional Tests', () => { expect(endpoints.eventIngestionEndpoint()).toBeNull(); }); }); - - describe('Edge cases and error handling', () => { - it('should handle extremely long subdomains', () => { - const longSubdomain = 'a'.repeat(100); - const mockToken = { - isValid: () => true, - getSubdomain: () => longSubdomain, - } as EnhancedSdkToken; - - const endpoints = new ApiEndpoints({ sdkToken: mockToken }); - expect(endpoints.endpoint('')).toContain(longSubdomain); - }); - - it('should handle unusual base URL formats', () => { - const endpoints = new ApiEndpoints({ - baseUrl: 'https://@:example.com:8080/path?query=value#fragment', - }); - // The exact handling will depend on implementation details, but it shouldn't throw - expect(() => endpoints.endpoint('test')).not.toThrow(); - }); - }); }); diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index 5bfc6b6..ff30942 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -38,9 +38,8 @@ export default class ApiEndpoints { if (protocolMatch) { return url; - } else { - return `${protocol}${url}`; } + return `${protocol}${url}`; } /** diff --git a/src/enhanced-sdk-token.spec.ts b/src/enhanced-sdk-token.spec.ts index 35d35bd..039b9be 100644 --- a/src/enhanced-sdk-token.spec.ts +++ b/src/enhanced-sdk-token.spec.ts @@ -28,10 +28,12 @@ describe('EnhancedSdkToken', () => { const tokenWithoutEh = new EnhancedSdkToken('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment expect(tokenWithoutEh.getEventIngestionHostname()).toBeNull(); expect(tokenWithoutEh.getSubdomain()).toEqual('experiment'); + expect(tokenWithoutEh.isValid()).toBeTruthy(); const tokenWithoutCs = new EnhancedSdkToken('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', () => { diff --git a/src/enhanced-sdk-token.ts b/src/enhanced-sdk-token.ts index 663741e..e3d386a 100644 --- a/src/enhanced-sdk-token.ts +++ b/src/enhanced-sdk-token.ts @@ -1,18 +1,14 @@ import { Base64 } from 'js-base64'; /** - * Represents an enhanced SDK token that can extract various fields from the token. + * An SDK Key with encoded data for customer-specific endpoints. */ export default class EnhancedSdkToken { private readonly encodedPayload: string | null; private readonly decodedParams: URLSearchParams | null; - /** - * Creates a new instance of EnhancedSdkToken. - * @param sdkToken The SDK token string to parse - */ - constructor(private readonly sdkToken: string) { - const parts = sdkToken.split('.'); + constructor(private readonly sdkKey: string) { + const parts = sdkKey.split('.'); this.encodedPayload = parts.length > 1 ? parts[1] : null; if (this.encodedPayload) { @@ -27,42 +23,27 @@ export default class EnhancedSdkToken { } } - /** - * Gets the value for a specific key from the decoded token. - * @param key The key to retrieve from the decoded parameters - * @returns The value for the key, or null if not found or if token is invalid - */ private getDecodedValue(key: string): string | null { return this.decodedParams?.get(key) || null; } - /** - * Gets the event ingestion hostname from the token. - * @returns The event ingestion hostname, or null if not present - */ getEventIngestionHostname(): string | null { return this.getDecodedValue('eh'); } - /** - * Gets the subdomain from the token. - * @returns The subdomain, or null if not present - */ getSubdomain(): string | null { return this.getDecodedValue('cs'); } /** - * Gets the raw token string. - * @returns The original SDK token string + * Gets the raw SDK Key. */ getToken(): string { - return this.sdkToken; + return this.sdkKey; } /** - * Checks if the token is valid (has encoded payload that can be decoded). - * @returns true if the token is valid, false otherwise + * Checks if the SDK Key had the subdomain or event hostname encoded. */ isValid(): boolean { return ( From 109d083ec911744e2b5f55027a34c7cd79047bb6 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 13:36:33 -0600 Subject: [PATCH 06/12] more tests --- src/api-endpoint.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index 121027b..ccf9279 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -72,6 +72,18 @@ describe('ApiEndpoints', () => { expect(endpoints.endpoint('')).toContain(customBaseUrl); }); + it('should not allow custom baseUrl to be the default base url', () => { + const customBaseUrl = 'https://custom-domain.com'; + // cs=test-subdomain + const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; + const endpoints = new ApiEndpoints({ + baseUrl: DEFAULT_BASE_URL, + sdkToken: new EnhancedSdkToken(sdkToken), + }); + + expect(endpoints.endpoint('/data')).toBe('https://test-subdomain.fscdn.eppo.cloud/api/data'); + }); + it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => { // eh=event-hostname const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU='; From 7045e4c5989902c15a0fc3975921e2f735067253 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 13:39:41 -0600 Subject: [PATCH 07/12] add to PR templates --- .github/pull_request_template.md | 3 +++ 1 file changed, 3 insertions(+) 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) From a85a492eda1f89787fc0045b86dc2c9d10e6f090 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 27 Mar 2025 13:42:58 -0600 Subject: [PATCH 08/12] v4.15.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": [ From d2b501d92f19b388b0aaa60e1ff4d257970ede65 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 28 Mar 2025 11:40:03 -0600 Subject: [PATCH 09/12] tweaks --- src/api-endpoint.spec.ts | 464 ++++++++---------- src/api-endpoints.ts | 24 +- src/client/eppo-client.ts | 4 +- .../configuration-wire-helper.ts | 4 +- src/constants.ts | 2 +- src/events/default-event-dispatcher.ts | 4 +- src/http-client.spec.ts | 11 +- ...-token.spec.ts => sdk-key-decoder.spec.ts} | 18 +- ...hanced-sdk-token.ts => sdk-key-decoder.ts} | 29 +- 9 files changed, 251 insertions(+), 309 deletions(-) rename src/{enhanced-sdk-token.spec.ts => sdk-key-decoder.spec.ts} (74%) rename src/{enhanced-sdk-token.ts => sdk-key-decoder.ts} (51%) diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index ccf9279..445d8a5 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -2,290 +2,248 @@ import * as td from 'testdouble'; import ApiEndpoints from './api-endpoints'; import { BASE_URL as DEFAULT_BASE_URL, DEFAULT_EVENT_DOMAIN } from './constants'; -import EnhancedSdkToken from './enhanced-sdk-token'; +import SdkKeyDecoder from './sdk-key-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', - }, - }); - 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('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); + }); + }); }); - 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('Base URL determination', () => { - it('should use custom baseUrl when provided', () => { - const customBaseUrl = 'https://custom-domain.com'; - const endpoints = new ApiEndpoints({ baseUrl: customBaseUrl }); - expect(endpoints.endpoint('')).toContain(customBaseUrl); - }); - - it('should use subdomain from SDK token when valid', () => { - // cs=test-subdomain - const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; - const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); - expect(endpoints.endpoint('/data')).toBe('https://test-subdomain.fscdn.eppo.cloud/api/data'); - }); - - it('should prefer custom baseUrl over SDK token subdomain', () => { - const customBaseUrl = 'https://custom-domain.com'; - // cs=test-subdomain - const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; - const endpoints = new ApiEndpoints({ - baseUrl: customBaseUrl, - sdkToken: new EnhancedSdkToken(sdkToken), - }); + 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 SdkKeyDecoder('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 SdkKeyDecoder('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 SdkKeyDecoder('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 SdkKeyDecoder('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 SdkKeyDecoder('invalid-token') }, + expected: 'https://fscdn.eppo.cloud/api/assignments', + }, + ]; - expect(endpoints.endpoint('')).toContain(customBaseUrl); - }); + testCases.forEach(({ name, params, expected }) => { + it(name, () => { + const endpoints = new ApiEndpoints(params); + const result = endpoints.precomputedFlagsEndpoint(); - it('should not allow custom baseUrl to be the default base url', () => { - const customBaseUrl = 'https://custom-domain.com'; - // cs=test-subdomain - const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; - const endpoints = new ApiEndpoints({ - baseUrl: DEFAULT_BASE_URL, - sdkToken: new EnhancedSdkToken(sdkToken), + expect(result).toBe(expected); }); - - expect(endpoints.endpoint('/data')).toBe('https://test-subdomain.fscdn.eppo.cloud/api/data'); - }); - - it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => { - // eh=event-hostname - const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU='; - const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); - expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy(); - }); - - it('should fallback to DEFAULT_BASE_URL when SDK token is invalid', () => { - const invalidToken = new EnhancedSdkToken('invalid-token'); - const endpoints = new ApiEndpoints({ sdkToken: invalidToken }); - expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy(); }); }); describe('Endpoint URL construction', () => { - it('should use effective base URL for UFC endpoint', () => { - // cs=test-subdomain - const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; - const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); - - expect(endpoints.ufcEndpoint()).toContain( - 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/config', - ); - }); - - it('should use effective base URL for bandit parameters endpoint', () => { - // cs=test-subdomain - const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; - const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) }); + const sdkTokenDecoder = new SdkKeyDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain - expect(endpoints.banditParametersEndpoint()).toContain( - 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/bandits', - ); - }); + 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', + }, + ]; - it('should use the subdomain and default base URL for precomputed flags endpoint', () => { - // cs=test-subdomain - const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4='; - const endpoints = new ApiEndpoints({ - sdkToken: new EnhancedSdkToken(sdkToken), - defaultUrl: 'default.eppo.cloud', + endpointTestCases.forEach(({ name, factory, expected }) => { + it(name, () => { + const endpoints = new ApiEndpoints({ sdkTokenDecoder: sdkTokenDecoder }); + const result = factory(endpoints); + expect(result).toBe(expected); }); - - expect(endpoints.precomputedFlagsEndpoint()).toContain('default.eppo.cloud'); - expect(endpoints.precomputedFlagsEndpoint()).toContain('test-subdomain'); - }); - - it('should have exactly one slash between base URL and resource', () => { - const baseUrlWithSlash = 'https://domain.com/'; - const baseUrlWithoutSlash = 'https://domain.com'; - const resourceWithSlash = '/resource'; - const resourceWithoutSlash = 'resource'; - - const endpoints1 = new ApiEndpoints({ baseUrl: baseUrlWithSlash }); - const endpoints2 = new ApiEndpoints({ baseUrl: baseUrlWithoutSlash }); - - // Test all combinations to ensure we avoid double slashes and always have one slash - expect(endpoints1.endpoint(resourceWithSlash)).toBe('https://domain.com/resource'); - expect(endpoints1.endpoint(resourceWithoutSlash)).toBe('https://domain.com/resource'); - expect(endpoints2.endpoint(resourceWithSlash)).toBe('https://domain.com/resource'); - expect(endpoints2.endpoint(resourceWithoutSlash)).toBe('https://domain.com/resource'); }); }); - describe('Event Url generation', () => { - const hostnameToken = new EnhancedSdkToken( + describe('Event ingestion URL', () => { + const hostnameToken = new SdkKeyDecoder( 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', ); - const mockedToken = td.object(); - beforeAll(() => { - td.when(mockedToken.isValid()).thenReturn(true); - }); - - it('should decode the event ingestion hostname from the SDK key', () => { - const endpoints = new ApiEndpoints({ sdkToken: hostnameToken }); - const hostname = endpoints.eventIngestionEndpoint(); - 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 - td.when(mockedToken.getEventIngestionHostname()).thenReturn('12 3456/.e.testing.eppo.cloud'); - const endpoints = new ApiEndpoints({ sdkToken: mockedToken }); - const hostname = endpoints.eventIngestionEndpoint(); - expect(hostname).toEqual('https://12 3456/.e.testing.eppo.cloud/v0/i'); - }); + let mockedDecoder: SdkKeyDecoder; - it("should return null if the SDK key doesn't contain the event ingestion hostname", () => { - td.when(mockedToken.isValid()).thenReturn(false); - const endpoints = new ApiEndpoints({ sdkToken: mockedToken }); - const hostname = endpoints.eventIngestionEndpoint(); - expect(hostname).toBeNull(); + beforeEach(() => { + mockedDecoder = td.object(); + td.when(mockedDecoder.isValid()).thenReturn(true); }); - }); - - describe('Query parameter handling', () => { - it('should append query parameters to endpoint URLs', () => { - const queryParams = { apiKey: 'test-key', sdkName: 'js-sdk', sdkVersion: '1.0.0' }; - const endpoints = new ApiEndpoints({ queryParams }); - - const url = endpoints.ufcEndpoint(); - expect(url).toContain('?'); - expect(url).toContain('apiKey=test-key'); - expect(url).toContain('sdkName=js-sdk'); - expect(url).toContain('sdkVersion=1.0.0'); - }); - - it('should properly encode query parameters with special characters', () => { - const queryParams = { - apiKey: 'test-key', - sdkName: 'value with spaces', - sdkVersion: 'a+b=c&d', - }; - const endpoints = new ApiEndpoints({ queryParams }); - - const url = endpoints.ufcEndpoint(); + 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, + }, + ]; - expect(url).toContain('sdkName=value+with+spaces'); - expect(url).toContain('sdkVersion=a%2Bb%3Dc%26d'); + eventUrlTestCases.forEach(({ name, setupDecoder, expected }) => { + it(name, () => { + const decoder = setupDecoder(); + const endpoints = new ApiEndpoints({ sdkTokenDecoder: decoder }); + expect(endpoints.eventIngestionEndpoint()).toEqual(expected); + }); }); }); -}); -describe('ApiEndpoints - Additional Tests', () => { describe('URL normalization', () => { - it('should preserve different protocol types', () => { - const httpEndpoints = new ApiEndpoints({ baseUrl: 'http://example.com' }); - const httpsEndpoints = new ApiEndpoints({ baseUrl: 'https://example.com' }); - const protocolRelativeEndpoints = new ApiEndpoints({ baseUrl: '//example.com' }); - - expect(httpEndpoints.endpoint('test')).toEqual('http://example.com/test'); - expect(httpsEndpoints.endpoint('test')).toEqual('https://example.com/test'); - expect(protocolRelativeEndpoints.endpoint('test')).toEqual('//example.com/test'); - }); - - it('should add https:// to URLs without protocols', () => { - const endpoints = new ApiEndpoints({ baseUrl: 'example.com' }); - expect(endpoints.endpoint('test')).toEqual('https://example.com/test'); - }); - - it('should handle multiple slashes', () => { - const endpoints = new ApiEndpoints({ baseUrl: 'example.com/' }); - expect(endpoints.endpoint('/test')).toEqual('https://example.com/test'); - }); - }); + 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', + }, + ]; - describe('Subdomain handling', () => { - it('should correctly integrate subdomain with base URLs containing paths', () => { - const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain - const endpoints = new ApiEndpoints({ - sdkToken, - defaultUrl: 'example.com/api/v2', + urlNormalizationTestCases.forEach(({ name, baseUrl, expected }) => { + it(`should ${name}`, () => { + const endpoints = new ApiEndpoints({ baseUrl }); + expect(endpoints.ufcEndpoint()).toEqual(expected); }); - - expect(endpoints.endpoint('')).toContain('https://test-subdomain.example.com/api/v2'); - }); - - it('should handle subdomains with special characters', () => { - // Token with cs=test-sub.domain-special encoded - const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWIuZG9tYWluLXNwZWNpYWw='); - const endpoints = new ApiEndpoints({ sdkToken }); - - expect(endpoints.endpoint('')).toContain('test-sub.domain-special'); - }); - }); - - describe('Event ingestion endpoint', () => { - it('should use subdomain with DEFAULT_EVENT_DOMAIN when hostname is not available', () => { - // Create a mock token with only a subdomain - const mockToken = { - isValid: () => true, - getEventIngestionHostname: () => null, - getSubdomain: () => 'test-subdomain', - } as EnhancedSdkToken; - - const endpoints = new ApiEndpoints({ sdkToken: mockToken }); - expect(endpoints.eventIngestionEndpoint()).toEqual( - `https://test-subdomain.${DEFAULT_EVENT_DOMAIN}/v0/i`, - ); - }); - - it('should prioritize hostname over subdomain if both are available', () => { - const mockToken = { - isValid: () => true, - getEventIngestionHostname: () => 'event-host.example.com', - getSubdomain: () => 'test-subdomain', - } as EnhancedSdkToken; - - const endpoints = new ApiEndpoints({ sdkToken: mockToken }); - expect(endpoints.eventIngestionEndpoint()).toEqual('https://event-host.example.com/v0/i'); - }); - - it('should return null when token is valid but no hostname or subdomain is available', () => { - const mockToken = { - isValid: () => true, - getEventIngestionHostname: () => null, - getSubdomain: () => null, - } as EnhancedSdkToken; - - const endpoints = new ApiEndpoints({ sdkToken: mockToken }); - expect(endpoints.eventIngestionEndpoint()).toBeNull(); }); }); }); diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index ff30942..3af5cc8 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -6,27 +6,27 @@ import { PRECOMPUTED_FLAGS_ENDPOINT, UFC_ENDPOINT, } from './constants'; -import EnhancedSdkToken from './enhanced-sdk-token'; import { IQueryParams, IQueryParamsWithSubject } from './http-client'; +import SdkKeyDecoder from './sdk-key-decoder'; interface IApiEndpointsParams { queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; defaultUrl: string; - sdkToken?: EnhancedSdkToken; + sdkTokenDecoder?: SdkKeyDecoder; } /** * Utility class for constructing Eppo API endpoint URLs */ export default class ApiEndpoints { - private readonly sdkToken: EnhancedSdkToken | null; + private readonly sdkToken: SdkKeyDecoder | null; private readonly _effectiveBaseUrl: string; private readonly params: IApiEndpointsParams; constructor(params: Partial) { this.params = Object.assign({}, { defaultUrl: BASE_URL }, params); - this.sdkToken = params.sdkToken ?? null; + this.sdkToken = params.sdkTokenDecoder ?? null; this._effectiveBaseUrl = this.determineBaseUrl(); } @@ -42,12 +42,6 @@ export default class ApiEndpoints { return `${protocol}${url}`; } - /** - * Determine the effective base URL based on the constructor parameters: - * 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 - */ private joinUrlParts(...parts: string[]): string { return parts .map((part) => part.trim()) @@ -61,7 +55,10 @@ export default class ApiEndpoints { } /** - * Determine the effective base URL based on the constructor parameters + * Determine the effective base URL: + * 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 */ private determineBaseUrl(): string { // If baseUrl is explicitly provided and different from default, use it @@ -82,10 +79,7 @@ export default class ApiEndpoints { return this.normalizeUrl(this.params.defaultUrl); } - /** - * Creates an endpoint URL with the specified resource path and query parameters - */ - endpoint(resource: string): string { + private endpoint(resource: string): string { const url = this.joinUrlParts(this._effectiveBaseUrl, resource); const queryParams = this.params.queryParams; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index b989d6c..3a8b6c3 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -30,7 +30,6 @@ import { DEFAULT_REQUEST_TIMEOUT_MS, } from '../constants'; import { decodeFlag } from '../decoding'; -import EnhancedSdkToken from '../enhanced-sdk-token'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; import { BoundedEventQueue } from '../events/bounded-event-queue'; @@ -57,6 +56,7 @@ import { import { getMD5Hash } from '../obfuscation'; import { OverridePayload, OverrideValidator } from '../override-validator'; import initPoller, { IPoller } from '../poller'; +import SdkKeyDecoder from '../sdk-key-decoder'; import { Attributes, AttributeType, @@ -330,7 +330,7 @@ export default class EppoClient { const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams: { apiKey, sdkName, sdkVersion }, - sdkToken: new EnhancedSdkToken(apiKey), + sdkTokenDecoder: new SdkKeyDecoder(apiKey), }); 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 af03b65..3c35999 100644 --- a/src/configuration-wire/configuration-wire-helper.ts +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -1,10 +1,10 @@ import ApiEndpoints from '../api-endpoints'; -import EnhancedSdkToken from '../enhanced-sdk-token'; import FetchHttpClient, { IBanditParametersResponse, IHttpClient, IUniversalFlagConfigResponse, } from '../http-client'; +import SdkKeyDecoder from '../sdk-key-decoder'; import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; @@ -46,7 +46,7 @@ export class ConfigurationWireHelper { const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams, - sdkToken: new EnhancedSdkToken(sdkKey), + sdkTokenDecoder: new SdkKeyDecoder(sdkKey), }); this.httpClient = new FetchHttpClient(apiEndpoints, 5000); diff --git a/src/constants.ts b/src/constants.ts index 95c22ee..b76096e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,7 +9,7 @@ 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 EVENT_ENDPOINT = '/v0/i'; export const PRECOMPUTED_BASE_URL = 'https://fs-edge-assignment.eppo.cloud'; export const PRECOMPUTED_FLAGS_ENDPOINT = '/assignments'; diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index a9547d9..329fabd 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -1,6 +1,6 @@ import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; -import EnhancedSdkToken from '../enhanced-sdk-token'; +import SdkKeyDecoder from '../sdk-key-decoder'; import BatchEventProcessor from './batch-event-processor'; import BatchRetryManager from './batch-retry-manager'; @@ -178,7 +178,7 @@ export function newDefaultEventDispatcher( batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, config: Omit = DEFAULT_EVENT_DISPATCHER_CONFIG, ): EventDispatcher { - const apiEndpointsHelper = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkKey) }); + const apiEndpointsHelper = new ApiEndpoints({ sdkTokenDecoder: new SdkKeyDecoder(sdkKey) }); const ingestionUrl = apiEndpointsHelper.eventIngestionEndpoint(); if (!ingestionUrl) { logger.debug( 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/enhanced-sdk-token.spec.ts b/src/sdk-key-decoder.spec.ts similarity index 74% rename from src/enhanced-sdk-token.spec.ts rename to src/sdk-key-decoder.spec.ts index 039b9be..fba10dd 100644 --- a/src/enhanced-sdk-token.spec.ts +++ b/src/sdk-key-decoder.spec.ts @@ -1,13 +1,13 @@ -import EnhancedSdkToken from './enhanced-sdk-token'; +import SdkKeyDecoder from './sdk-key-decoder'; describe('EnhancedSdkToken', () => { it('should extract the event ingestion hostname from the SDK token', () => { - const token = new EnhancedSdkToken('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'); + const token = new SdkKeyDecoder('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'); expect(token.getEventIngestionHostname()).toEqual('123456.e.testing.eppo.cloud'); }); it('should extract the subdomain from the SDK token', () => { - const token = new EnhancedSdkToken( + const token = new SdkKeyDecoder( 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudCZlaD1hYmMxMjMuZXBwby5jbG91ZA==', ); expect(token.getSubdomain()).toEqual('experiment'); @@ -18,31 +18,31 @@ describe('EnhancedSdkToken', () => { // 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 EnhancedSdkToken(`zCsQuoHJxVPp895.${encoded}`); + const token = new SdkKeyDecoder(`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 EnhancedSdkToken('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment + const tokenWithoutEh = new SdkKeyDecoder('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment expect(tokenWithoutEh.getEventIngestionHostname()).toBeNull(); expect(tokenWithoutEh.getSubdomain()).toEqual('experiment'); expect(tokenWithoutEh.isValid()).toBeTruthy(); - const tokenWithoutCs = new EnhancedSdkToken('zCsQuoHJxVPp895.ZWg9YWJjMTIzLmVwcG8uY2xvdWQ='); // only eh=abc123.eppo.cloud + const tokenWithoutCs = new SdkKeyDecoder('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 EnhancedSdkToken('zCsQuoHJxVPp895'); + const invalidToken = new SdkKeyDecoder('zCsQuoHJxVPp895'); expect(invalidToken.getEventIngestionHostname()).toBeNull(); expect(invalidToken.getSubdomain()).toBeNull(); expect(invalidToken.isValid()).toBeFalsy(); - const invalidEncodingToken = new EnhancedSdkToken('zCsQuoHJxVPp895.%%%'); + const invalidEncodingToken = new SdkKeyDecoder('zCsQuoHJxVPp895.%%%'); expect(invalidEncodingToken.getEventIngestionHostname()).toBeNull(); expect(invalidEncodingToken.getSubdomain()).toBeNull(); expect(invalidEncodingToken.isValid()).toBeFalsy(); @@ -50,7 +50,7 @@ describe('EnhancedSdkToken', () => { it('should provide access to the original token string', () => { const tokenString = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'; - const token = new EnhancedSdkToken(tokenString); + const token = new SdkKeyDecoder(tokenString); expect(token.getToken()).toEqual(tokenString); }); }); diff --git a/src/enhanced-sdk-token.ts b/src/sdk-key-decoder.ts similarity index 51% rename from src/enhanced-sdk-token.ts rename to src/sdk-key-decoder.ts index e3d386a..89a1646 100644 --- a/src/enhanced-sdk-token.ts +++ b/src/sdk-key-decoder.ts @@ -1,24 +1,19 @@ import { Base64 } from 'js-base64'; /** - * An SDK Key with encoded data for customer-specific endpoints. + * Decodes SDK tokens with embedded encoded data. */ -export default class EnhancedSdkToken { - private readonly encodedPayload: string | null; +export default class SdkKeyDecoder { private readonly decodedParams: URLSearchParams | null; constructor(private readonly sdkKey: string) { - const parts = sdkKey.split('.'); - this.encodedPayload = parts.length > 1 ? parts[1] : null; - - if (this.encodedPayload) { - try { - const decodedPayload = Base64.decode(this.encodedPayload); - this.decodedParams = new URLSearchParams(decodedPayload); - } catch (e) { - this.decodedParams = null; - } - } else { + try { + const [, payload] = sdkKey.split('.'); + const encodedPayload = payload ?? null; + this.decodedParams = encodedPayload + ? new URLSearchParams(Base64.decode(encodedPayload)) + : null; + } catch { this.decodedParams = null; } } @@ -46,9 +41,7 @@ export default class EnhancedSdkToken { * Checks if the SDK Key had the subdomain or event hostname encoded. */ isValid(): boolean { - return ( - this.decodedParams !== null && - (this.getSubdomain() !== null || this.getEventIngestionHostname() !== null) - ); + if (!this.decodedParams) return false; + return !!this.getEventIngestionHostname() || !!this.getSubdomain(); } } From 3541aa12aea48967c7f5ba14ad0228b73509c1ba Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 28 Mar 2025 13:17:15 -0600 Subject: [PATCH 10/12] better docs and helper --- src/api-endpoints.ts | 61 +++++++++++++++++++++++++- src/events/default-event-dispatcher.ts | 4 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index 3af5cc8..0c4c953 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -9,6 +9,14 @@ import { import { IQueryParams, IQueryParamsWithSubject } from './http-client'; import SdkKeyDecoder from './sdk-key-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; @@ -17,7 +25,17 @@ interface IApiEndpointsParams { } /** - * Utility class for constructing Eppo API endpoint URLs + * 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 endpoints - always uses the event domain (e.eppo.cloud) with either the + * 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(sdkTokenDecoder)` */ export default class ApiEndpoints { private readonly sdkToken: SdkKeyDecoder | null; @@ -30,6 +48,16 @@ export default class ApiEndpoints { 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 SdkKeyDecoder(sdkToken), + }).eventIngestionEndpoint(); + } + /** * Normalizes a URL by ensuring proper protocol and removing trailing slashes */ @@ -55,10 +83,12 @@ export default class ApiEndpoints { } /** - * Determine the effective base URL: + * 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 @@ -93,18 +123,45 @@ export default class ApiEndpoints { 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; diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index 329fabd..668ebc2 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -1,6 +1,5 @@ import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; -import SdkKeyDecoder from '../sdk-key-decoder'; import BatchEventProcessor from './batch-event-processor'; import BatchRetryManager from './batch-retry-manager'; @@ -178,8 +177,7 @@ export function newDefaultEventDispatcher( batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, config: Omit = DEFAULT_EVENT_DISPATCHER_CONFIG, ): EventDispatcher { - const apiEndpointsHelper = new ApiEndpoints({ sdkTokenDecoder: new SdkKeyDecoder(sdkKey) }); - const ingestionUrl = apiEndpointsHelper.eventIngestionEndpoint(); + 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', From 62e8b04cb8d1efcb5479616f5b9dc81abce248a0 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 28 Mar 2025 13:19:49 -0600 Subject: [PATCH 11/12] tweak docs --- src/api-endpoints.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index 0c4c953..036c818 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -30,12 +30,11 @@ interface IApiEndpointsParams { * 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 endpoints - always uses the event domain (e.eppo.cloud) with either the - * subdomain from SDK token or a full hostname from SDK token. This endpoint IGNORES the - * baseUrl and defaultUrl parameters. + * 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(sdkTokenDecoder)` + * `ApiEndpoints.createEventIngestionUrl(sdkKey)` */ export default class ApiEndpoints { private readonly sdkToken: SdkKeyDecoder | null; From 1259ec73419c82b1028465584a5e83ec1bd6a090 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 28 Mar 2025 13:40:39 -0600 Subject: [PATCH 12/12] rename SdkKeyDecoder to SdkTokenDecoder --- src/api-endpoint.spec.ts | 20 +++++++++---------- src/api-endpoints.ts | 8 ++++---- src/client/eppo-client.ts | 4 ++-- .../configuration-wire-helper.ts | 4 ++-- ...oder.spec.ts => sdk-token-decoder.spec.ts} | 18 ++++++++--------- ...dk-key-decoder.ts => sdk-token-decoder.ts} | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) rename src/{sdk-key-decoder.spec.ts => sdk-token-decoder.spec.ts} (74%) rename src/{sdk-key-decoder.ts => sdk-token-decoder.ts} (96%) diff --git a/src/api-endpoint.spec.ts b/src/api-endpoint.spec.ts index 445d8a5..ab2252f 100644 --- a/src/api-endpoint.spec.ts +++ b/src/api-endpoint.spec.ts @@ -2,7 +2,7 @@ import * as td from 'testdouble'; import ApiEndpoints from './api-endpoints'; import { BASE_URL as DEFAULT_BASE_URL, DEFAULT_EVENT_DOMAIN } from './constants'; -import SdkKeyDecoder from './sdk-key-decoder'; +import SdkTokenDecoder from './sdk-token-decoder'; describe('ApiEndpoints', () => { describe('Query parameters', () => { @@ -71,14 +71,14 @@ describe('ApiEndpoints', () => { }, { name: 'should use subdomain from SDK token when valid', - params: { sdkTokenDecoder: new SdkKeyDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4=') }, + 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 SdkKeyDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='), + sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='), }, expected: 'https://custom-domain.com/assignments', }, @@ -86,18 +86,18 @@ describe('ApiEndpoints', () => { name: 'should not allow custom baseUrl to be the default base url', params: { baseUrl: DEFAULT_BASE_URL, - sdkTokenDecoder: new SdkKeyDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='), + 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 SdkKeyDecoder('abc.ZWg9ZXZlbnQtaG9zdG5hbWU=') }, + 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 SdkKeyDecoder('invalid-token') }, + params: { sdkTokenDecoder: new SdkTokenDecoder('invalid-token') }, expected: 'https://fscdn.eppo.cloud/api/assignments', }, ]; @@ -113,7 +113,7 @@ describe('ApiEndpoints', () => { }); describe('Endpoint URL construction', () => { - const sdkTokenDecoder = new SdkKeyDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain + const sdkTokenDecoder = new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain const endpointTestCases = [ { @@ -138,13 +138,13 @@ describe('ApiEndpoints', () => { }); describe('Event ingestion URL', () => { - const hostnameToken = new SdkKeyDecoder( + const hostnameToken = new SdkTokenDecoder( 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', ); - let mockedDecoder: SdkKeyDecoder; + let mockedDecoder: SdkTokenDecoder; beforeEach(() => { - mockedDecoder = td.object(); + mockedDecoder = td.object(); td.when(mockedDecoder.isValid()).thenReturn(true); }); diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index 036c818..a28f464 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -7,7 +7,7 @@ import { UFC_ENDPOINT, } from './constants'; import { IQueryParams, IQueryParamsWithSubject } from './http-client'; -import SdkKeyDecoder from './sdk-key-decoder'; +import SdkTokenDecoder from './sdk-token-decoder'; /** * Parameters for configuring the API endpoints @@ -21,7 +21,7 @@ interface IApiEndpointsParams { queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; defaultUrl: string; - sdkTokenDecoder?: SdkKeyDecoder; + sdkTokenDecoder?: SdkTokenDecoder; } /** @@ -37,7 +37,7 @@ interface IApiEndpointsParams { * `ApiEndpoints.createEventIngestionUrl(sdkKey)` */ export default class ApiEndpoints { - private readonly sdkToken: SdkKeyDecoder | null; + private readonly sdkToken: SdkTokenDecoder | null; private readonly _effectiveBaseUrl: string; private readonly params: IApiEndpointsParams; @@ -53,7 +53,7 @@ export default class ApiEndpoints { */ static createEventIngestionUrl(sdkToken: string): string | null { return new ApiEndpoints({ - sdkTokenDecoder: new SdkKeyDecoder(sdkToken), + sdkTokenDecoder: new SdkTokenDecoder(sdkToken), }).eventIngestionEndpoint(); } diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 3a8b6c3..e7d9c6d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -56,7 +56,7 @@ import { import { getMD5Hash } from '../obfuscation'; import { OverridePayload, OverrideValidator } from '../override-validator'; import initPoller, { IPoller } from '../poller'; -import SdkKeyDecoder from '../sdk-key-decoder'; +import SdkTokenDecoder from '../sdk-token-decoder'; import { Attributes, AttributeType, @@ -330,7 +330,7 @@ export default class EppoClient { const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams: { apiKey, sdkName, sdkVersion }, - sdkTokenDecoder: new SdkKeyDecoder(apiKey), + sdkTokenDecoder: new SdkTokenDecoder(apiKey), }); 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 3c35999..189c9c3 100644 --- a/src/configuration-wire/configuration-wire-helper.ts +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -4,7 +4,7 @@ import FetchHttpClient, { IHttpClient, IUniversalFlagConfigResponse, } from '../http-client'; -import SdkKeyDecoder from '../sdk-key-decoder'; +import SdkTokenDecoder from '../sdk-token-decoder'; import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; @@ -46,7 +46,7 @@ export class ConfigurationWireHelper { const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams, - sdkTokenDecoder: new SdkKeyDecoder(sdkKey), + sdkTokenDecoder: new SdkTokenDecoder(sdkKey), }); this.httpClient = new FetchHttpClient(apiEndpoints, 5000); diff --git a/src/sdk-key-decoder.spec.ts b/src/sdk-token-decoder.spec.ts similarity index 74% rename from src/sdk-key-decoder.spec.ts rename to src/sdk-token-decoder.spec.ts index fba10dd..2fa0233 100644 --- a/src/sdk-key-decoder.spec.ts +++ b/src/sdk-token-decoder.spec.ts @@ -1,13 +1,13 @@ -import SdkKeyDecoder from './sdk-key-decoder'; +import SdkTokenDecoder from './sdk-token-decoder'; describe('EnhancedSdkToken', () => { it('should extract the event ingestion hostname from the SDK token', () => { - const token = new SdkKeyDecoder('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'); + 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 SdkKeyDecoder( + const token = new SdkTokenDecoder( 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudCZlaD1hYmMxMjMuZXBwby5jbG91ZA==', ); expect(token.getSubdomain()).toEqual('experiment'); @@ -18,31 +18,31 @@ describe('EnhancedSdkToken', () => { // 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 SdkKeyDecoder(`zCsQuoHJxVPp895.${encoded}`); + 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 SdkKeyDecoder('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment + 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 SdkKeyDecoder('zCsQuoHJxVPp895.ZWg9YWJjMTIzLmVwcG8uY2xvdWQ='); // only eh=abc123.eppo.cloud + 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 SdkKeyDecoder('zCsQuoHJxVPp895'); + const invalidToken = new SdkTokenDecoder('zCsQuoHJxVPp895'); expect(invalidToken.getEventIngestionHostname()).toBeNull(); expect(invalidToken.getSubdomain()).toBeNull(); expect(invalidToken.isValid()).toBeFalsy(); - const invalidEncodingToken = new SdkKeyDecoder('zCsQuoHJxVPp895.%%%'); + const invalidEncodingToken = new SdkTokenDecoder('zCsQuoHJxVPp895.%%%'); expect(invalidEncodingToken.getEventIngestionHostname()).toBeNull(); expect(invalidEncodingToken.getSubdomain()).toBeNull(); expect(invalidEncodingToken.isValid()).toBeFalsy(); @@ -50,7 +50,7 @@ describe('EnhancedSdkToken', () => { it('should provide access to the original token string', () => { const tokenString = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'; - const token = new SdkKeyDecoder(tokenString); + const token = new SdkTokenDecoder(tokenString); expect(token.getToken()).toEqual(tokenString); }); }); diff --git a/src/sdk-key-decoder.ts b/src/sdk-token-decoder.ts similarity index 96% rename from src/sdk-key-decoder.ts rename to src/sdk-token-decoder.ts index 89a1646..3f03137 100644 --- a/src/sdk-key-decoder.ts +++ b/src/sdk-token-decoder.ts @@ -3,7 +3,7 @@ import { Base64 } from 'js-base64'; /** * Decodes SDK tokens with embedded encoded data. */ -export default class SdkKeyDecoder { +export default class SdkTokenDecoder { private readonly decodedParams: URLSearchParams | null; constructor(private readonly sdkKey: string) {