Skip to content

Commit 8701dd4

Browse files
committed
wip
1 parent 246c2ae commit 8701dd4

11 files changed

+364
-46
lines changed

src/api-endpoint.spec.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import ApiEndpoints from './api-endpoints';
22
import { BASE_URL as DEFAULT_BASE_URL } from './constants';
3+
import EnhancedSdkToken from './enhanced-sdk-token';
34

45
describe('ApiEndpoints', () => {
56
it('should append query parameters to the URL', () => {
67
const apiEndpoints = new ApiEndpoints({
7-
baseUrl: 'http://api.example.com',
8+
baseUrl: 'https://api.example.com',
89
queryParams: {
910
apiKey: '12345',
1011
sdkVersion: 'foobar',
1112
sdkName: 'ExampleSDK',
1213
},
1314
});
1415
expect(apiEndpoints.endpoint('/data').toString()).toEqual(
15-
'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
16+
'https://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
1617
);
1718
expect(apiEndpoints.ufcEndpoint().toString()).toEqual(
18-
'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
19+
'https://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
1920
);
2021
});
2122

@@ -42,4 +43,121 @@ describe('ApiEndpoints', () => {
4243
`${DEFAULT_BASE_URL}/flag-config/v1/config`,
4344
);
4445
});
46+
47+
describe('Base URL determination', () => {
48+
it('should use custom baseUrl when provided', () => {
49+
const customBaseUrl = 'https://custom-domain.com';
50+
const endpoints = new ApiEndpoints({ baseUrl: customBaseUrl });
51+
expect(endpoints.endpoint('')).toContain(customBaseUrl);
52+
});
53+
54+
it('should use subdomain from SDK token when valid', () => {
55+
// This token has cs=test-subdomain
56+
const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4=';
57+
const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) });
58+
expect(endpoints.getEffectiveBaseUrl()).toBe('https://test-subdomain.fscdn.eppo.cloud/api');
59+
});
60+
61+
it('should prefer custom baseUrl over SDK token subdomain', () => {
62+
const customBaseUrl = 'https://custom-domain.com';
63+
// This token has cs=test-subdomain
64+
const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4=';
65+
const endpoints = new ApiEndpoints({
66+
baseUrl: customBaseUrl,
67+
sdkToken: new EnhancedSdkToken(sdkToken),
68+
});
69+
expect(endpoints.getEffectiveBaseUrl()).toBe(customBaseUrl);
70+
});
71+
72+
it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => {
73+
// This token has no cs parameter
74+
const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU=';
75+
const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) });
76+
expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL);
77+
});
78+
79+
it('should fallback to DEFAULT_BASE_URL when SDK token is invalid', () => {
80+
const invalidToken = new EnhancedSdkToken('invalid-token');
81+
const endpoints = new ApiEndpoints({ sdkToken: invalidToken });
82+
expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL);
83+
});
84+
});
85+
86+
describe('Endpoint URL construction', () => {
87+
it('should use effective base URL for UFC endpoint', () => {
88+
// This token has cs=test-subdomain
89+
const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4=';
90+
const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) });
91+
92+
expect(endpoints.ufcEndpoint()).toContain(
93+
'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/config',
94+
);
95+
});
96+
97+
it('should use effective base URL for bandit parameters endpoint', () => {
98+
// This token has cs=test-subdomain
99+
const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4=';
100+
const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) });
101+
102+
expect(endpoints.banditParametersEndpoint()).toContain(
103+
'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/bandits',
104+
);
105+
});
106+
107+
it('should use the sub-domain and default base URL for precomputed flags endpoint', () => {
108+
// This token has cs=test-subdomain
109+
const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4=';
110+
const endpoints = new ApiEndpoints({
111+
sdkToken: new EnhancedSdkToken(sdkToken),
112+
defaultUrl: 'default.eppo.cloud',
113+
});
114+
115+
expect(endpoints.precomputedFlagsEndpoint()).toContain('default.eppo.cloud');
116+
expect(endpoints.precomputedFlagsEndpoint()).toContain('test-subdomain');
117+
});
118+
119+
it('should handle slash management between base URL and resource', () => {
120+
const baseUrlWithSlash = 'https://domain.com/';
121+
const baseUrlWithoutSlash = 'https://domain.com';
122+
const resourceWithSlash = '/resource';
123+
const resourceWithoutSlash = 'resource';
124+
125+
const endpoints1 = new ApiEndpoints({ baseUrl: baseUrlWithSlash });
126+
const endpoints2 = new ApiEndpoints({ baseUrl: baseUrlWithoutSlash });
127+
128+
// Test all combinations to ensure we avoid double slashes and always have one slash
129+
expect(endpoints1.endpoint(resourceWithSlash)).toBe('https://domain.com/resource');
130+
expect(endpoints1.endpoint(resourceWithoutSlash)).toBe('https://domain.com/resource');
131+
expect(endpoints2.endpoint(resourceWithSlash)).toBe('https://domain.com/resource');
132+
expect(endpoints2.endpoint(resourceWithoutSlash)).toBe('https://domain.com/resource');
133+
});
134+
});
135+
136+
describe('Query parameter handling', () => {
137+
it('should append query parameters to endpoint URLs', () => {
138+
const queryParams = { apiKey: 'test-key', sdkName: 'js-sdk', sdkVersion: '1.0.0' };
139+
const endpoints = new ApiEndpoints({ queryParams });
140+
141+
const url = endpoints.ufcEndpoint();
142+
143+
expect(url).toContain('?');
144+
expect(url).toContain('apiKey=test-key');
145+
expect(url).toContain('sdkName=js-sdk');
146+
expect(url).toContain('sdkVersion=1.0.0');
147+
});
148+
149+
it('should properly encode query parameters with special characters', () => {
150+
const queryParams = {
151+
apiKey: 'test-key',
152+
sdkName: 'value with spaces',
153+
sdkVersion: 'a+b=c&d',
154+
};
155+
const endpoints = new ApiEndpoints({ queryParams });
156+
157+
const url = endpoints.ufcEndpoint();
158+
159+
expect(url).toContain('sdkName=value+with+spaces');
160+
expect(url).toContain('sdkVersion=a%2Bb%3Dc%26d');
161+
});
162+
});
45163
});

src/api-endpoints.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,109 @@
1-
import {
2-
BASE_URL as DEFAULT_BASE_URL,
3-
UFC_ENDPOINT,
4-
BANDIT_ENDPOINT,
5-
PRECOMPUTED_FLAGS_ENDPOINT,
6-
} from './constants';
1+
import { BANDIT_ENDPOINT, BASE_URL, PRECOMPUTED_FLAGS_ENDPOINT, UFC_ENDPOINT } from './constants';
2+
import EnhancedSdkToken from './enhanced-sdk-token';
73
import { IQueryParams, IQueryParamsWithSubject } from './http-client';
84

95
interface IApiEndpointsParams {
106
queryParams?: IQueryParams | IQueryParamsWithSubject;
117
baseUrl?: string;
8+
defaultUrl: string;
9+
sdkToken?: EnhancedSdkToken;
1210
}
1311

1412
/** Utility class for constructing an Eppo API endpoint URL given a provided baseUrl and query parameters */
1513
export default class ApiEndpoints {
16-
constructor(private readonly params: IApiEndpointsParams) {
17-
this.params.baseUrl = params.baseUrl ?? DEFAULT_BASE_URL;
14+
private readonly sdkToken: EnhancedSdkToken | null;
15+
private readonly _effectiveBaseUrl: string;
16+
private readonly params: IApiEndpointsParams;
17+
18+
constructor(params: Partial<IApiEndpointsParams>) {
19+
this.params = Object.assign({}, { defaultUrl: BASE_URL }, params);
20+
this.sdkToken = params.sdkToken ?? null;
21+
22+
// this.params.baseUrl =
23+
// params.baseUrl && params.baseUrl !== DEFAULT_BASE_URL ? params.baseUrl : DEFAULT_URL;
24+
25+
// Set the effective base URL.
26+
this._effectiveBaseUrl = this.determineBaseUrl();
27+
}
28+
29+
/**
30+
* Determine the effective base URL based on the constructor parameters:
31+
* 1. If baseUrl is provided, and it is not equal to the DEFAULT_BASE_URL, use it
32+
* 2. If the api key contains an encoded customer-specific subdomain, use it with DEFAULT_DOMAIN
33+
* 3. Otherwise, fall back to DEFAULT_BASE_URL
34+
*/
35+
private determineBaseUrl(): string {
36+
// If baseUrl is explicitly provided and different from default, use it
37+
if (this.params.baseUrl && this.params.baseUrl !== this.params.defaultUrl) {
38+
return this.params.baseUrl;
39+
}
40+
41+
// If there's an enhanced SDK token with a subdomain, it will be prepended in the buildUrl method.
42+
const subdomain = this.sdkToken?.getSubdomain();
43+
return this.buildUrl(this.params.defaultUrl, subdomain);
44+
}
45+
46+
private buildUrl(domain: string, subdomain?: string | null) {
47+
const protocol = ApiEndpoints.URL_PROTOCOLS.find((v) => domain.startsWith(v)) ?? 'https://';
48+
49+
const base = this.stripProtocol(domain);
50+
return subdomain ? `${protocol}${subdomain}.${base}` : `${protocol}${base}`;
51+
}
52+
53+
/**
54+
* Returns the base URL being used for the UFC and bandit endpoints
55+
*/
56+
getEffectiveBaseUrl(): string {
57+
return this._effectiveBaseUrl;
1858
}
1959

60+
/**
61+
* Creates an endpoint URL with the specified resource path and query parameters
62+
*/
2063
endpoint(resource: string): string {
21-
const endpointUrl = `${this.params.baseUrl}${resource}`;
64+
const baseUrl = this._effectiveBaseUrl;
65+
66+
// Ensure baseUrl and resource join correctly with only one slash
67+
const base = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
68+
const path = resource.startsWith('/') ? resource.substring(1) : resource;
69+
const endpointUrl = `${base}/${path}`;
70+
2271
const queryParams = this.params.queryParams;
2372
if (!queryParams) {
2473
return endpointUrl;
2574
}
75+
2676
const urlSearchParams = new URLSearchParams();
2777
Object.entries(queryParams).forEach(([key, value]) => urlSearchParams.append(key, value));
78+
2879
return `${endpointUrl}?${urlSearchParams}`;
2980
}
3081

82+
/**
83+
* Returns the URL for the UFC endpoint
84+
*/
3185
ufcEndpoint(): string {
3286
return this.endpoint(UFC_ENDPOINT);
3387
}
3488

89+
/**
90+
* Returns the URL for the bandit parameters endpoint
91+
*/
3592
banditParametersEndpoint(): string {
3693
return this.endpoint(BANDIT_ENDPOINT);
3794
}
3895

96+
/**
97+
* Returns the URL for the precomputed flags endpoint
98+
*/
3999
precomputedFlagsEndpoint(): string {
40100
return this.endpoint(PRECOMPUTED_FLAGS_ENDPOINT);
41101
}
102+
103+
private stripProtocol(url: string) {
104+
return ApiEndpoints.URL_PROTOCOLS.reduce((prev, cur) => {
105+
return prev.replace(cur, '');
106+
}, url);
107+
}
108+
public static readonly URL_PROTOCOLS = ['http://', 'https://', '//'];
42109
}

src/client/eppo-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
DEFAULT_REQUEST_TIMEOUT_MS,
3131
} from '../constants';
3232
import { decodeFlag } from '../decoding';
33+
import EnhancedSdkToken from '../enhanced-sdk-token';
3334
import { EppoValue } from '../eppo_value';
3435
import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator';
3536
import { BoundedEventQueue } from '../events/bounded-event-queue';
@@ -326,11 +327,12 @@ export default class EppoClient {
326327
pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS;
327328
}
328329

329-
// todo: Inject the chain of dependencies below
330330
const apiEndpoints = new ApiEndpoints({
331331
baseUrl,
332332
queryParams: { apiKey, sdkName, sdkVersion },
333+
sdkToken: new EnhancedSdkToken(apiKey),
333334
});
335+
334336
const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs);
335337
const configurationRequestor = new ConfigurationRequestor(
336338
httpClient,

src/client/eppo-precomputed-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ export default class EppoPrecomputedClient {
161161

162162
// todo: Inject the chain of dependencies below
163163
const apiEndpoints = new ApiEndpoints({
164-
baseUrl: baseUrl ?? PRECOMPUTED_BASE_URL,
164+
defaultUrl: PRECOMPUTED_BASE_URL,
165+
baseUrl,
165166
queryParams: { apiKey, sdkName, sdkVersion },
166167
});
167168
const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs);

src/configuration-wire/configuration-wire-helper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ApiEndpoints from '../api-endpoints';
2+
import EnhancedSdkToken from '../enhanced-sdk-token';
23
import FetchHttpClient, {
34
IBanditParametersResponse,
45
IHttpClient,
@@ -45,6 +46,7 @@ export class ConfigurationWireHelper {
4546
const apiEndpoints = new ApiEndpoints({
4647
baseUrl,
4748
queryParams,
49+
sdkToken: new EnhancedSdkToken(sdkKey),
4850
});
4951

5052
this.httpClient = new FetchHttpClient(apiEndpoints, 5000);

src/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ export const DEFAULT_POLL_INTERVAL_MS = 30000;
66
export const POLL_JITTER_PCT = 0.1;
77
export const DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES = 1;
88
export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7;
9-
export const BASE_URL = 'https://fscdn.eppo.cloud/api';
9+
export const DEFAULT_URL = 'fscdn.eppo.cloud/api';
10+
export const BASE_URL = 'https://' + DEFAULT_URL;
1011
export const UFC_ENDPOINT = '/flag-config/v1/config';
1112
export const BANDIT_ENDPOINT = '/flag-config/v1/bandits';
1213
export const PRECOMPUTED_BASE_URL = 'https://fs-edge-assignment.eppo.cloud';
1314
export const PRECOMPUTED_FLAGS_ENDPOINT = '/assignments';
15+
export const DEFAULT_EVENT_DOMAIN = 'e.eppo.cloud';
16+
1417
export const SESSION_ASSIGNMENT_CONFIG_LOADED = 'eppo-session-assignment-config-loaded';
1518
export const NULL_SENTINEL = 'EPPO_NULL';
1619
// number of logging events that may be queued while waiting for initialization

src/enhanced-sdk-token.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import EnhancedSdkToken from './enhanced-sdk-token';
2+
3+
describe('EnhancedSdkToken', () => {
4+
it('should extract the event ingestion hostname from the SDK token', () => {
5+
const token = new EnhancedSdkToken('zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk');
6+
expect(token.getEventIngestionHostname()).toEqual('123456.e.testing.eppo.cloud');
7+
});
8+
9+
it('should extract the subdomain from the SDK token', () => {
10+
const token = new EnhancedSdkToken(
11+
'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudCZlaD1hYmMxMjMuZXBwby5jbG91ZA==',
12+
);
13+
expect(token.getSubdomain()).toEqual('experiment');
14+
expect(token.getEventIngestionHostname()).toEqual('abc123.eppo.cloud');
15+
});
16+
17+
it('should handle tokens with non URL-safe characters', () => {
18+
// Include both eh and cs parameters with special characters
19+
const params = 'eh=12+3456/.e.testing.eppo.cloud&cs=test+subdomain/special';
20+
const encoded = Buffer.from(params).toString('base64url');
21+
const token = new EnhancedSdkToken(`zCsQuoHJxVPp895.${encoded}`);
22+
23+
expect(token.getEventIngestionHostname()).toEqual('12 3456/.e.testing.eppo.cloud');
24+
expect(token.getSubdomain()).toEqual('test subdomain/special');
25+
});
26+
27+
it('should return null for tokens without the required parameter', () => {
28+
const tokenWithoutEh = new EnhancedSdkToken('zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA=='); // only cs=experiment
29+
expect(tokenWithoutEh.getEventIngestionHostname()).toBeNull();
30+
expect(tokenWithoutEh.getSubdomain()).toEqual('experiment');
31+
32+
const tokenWithoutCs = new EnhancedSdkToken('zCsQuoHJxVPp895.ZWg9YWJjMTIzLmVwcG8uY2xvdWQ='); // only eh=abc123.eppo.cloud
33+
expect(tokenWithoutCs.getEventIngestionHostname()).toEqual('abc123.eppo.cloud');
34+
expect(tokenWithoutCs.getSubdomain()).toBeNull();
35+
});
36+
37+
it('should handle invalid tokens', () => {
38+
const invalidToken = new EnhancedSdkToken('zCsQuoHJxVPp895');
39+
expect(invalidToken.getEventIngestionHostname()).toBeNull();
40+
expect(invalidToken.getSubdomain()).toBeNull();
41+
expect(invalidToken.isValid()).toBeFalsy();
42+
43+
const invalidEncodingToken = new EnhancedSdkToken('zCsQuoHJxVPp895.%%%');
44+
expect(invalidEncodingToken.getEventIngestionHostname()).toBeNull();
45+
expect(invalidEncodingToken.getSubdomain()).toBeNull();
46+
expect(invalidEncodingToken.isValid()).toBeFalsy();
47+
});
48+
49+
it('should provide access to the original token string', () => {
50+
const tokenString = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk';
51+
const token = new EnhancedSdkToken(tokenString);
52+
expect(token.getToken()).toEqual(tokenString);
53+
});
54+
});

0 commit comments

Comments
 (0)