Skip to content

Commit 23f25a1

Browse files
committed
move event URL into api endpoints
1 parent 8701dd4 commit 23f25a1

File tree

5 files changed

+226
-104
lines changed

5 files changed

+226
-104
lines changed

src/api-endpoint.spec.ts

Lines changed: 149 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1+
import * as td from 'testdouble';
2+
13
import ApiEndpoints from './api-endpoints';
2-
import { BASE_URL as DEFAULT_BASE_URL } from './constants';
4+
import { BASE_URL as DEFAULT_BASE_URL, DEFAULT_EVENT_DOMAIN } from './constants';
35
import EnhancedSdkToken from './enhanced-sdk-token';
46

57
describe('ApiEndpoints', () => {
68
it('should append query parameters to the URL', () => {
79
const apiEndpoints = new ApiEndpoints({
8-
baseUrl: 'https://api.example.com',
10+
baseUrl: 'http://api.example.com',
911
queryParams: {
1012
apiKey: '12345',
1113
sdkVersion: 'foobar',
1214
sdkName: 'ExampleSDK',
1315
},
1416
});
1517
expect(apiEndpoints.endpoint('/data').toString()).toEqual(
16-
'https://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
18+
'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
1719
);
1820
expect(apiEndpoints.ufcEndpoint().toString()).toEqual(
19-
'https://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
21+
'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
2022
);
2123
});
2224

@@ -55,7 +57,7 @@ describe('ApiEndpoints', () => {
5557
// This token has cs=test-subdomain
5658
const sdkToken = 'abc.Y3M9dGVzdC1zdWJkb21haW4=';
5759
const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) });
58-
expect(endpoints.getEffectiveBaseUrl()).toBe('https://test-subdomain.fscdn.eppo.cloud/api');
60+
expect(endpoints.endpoint('/data')).toBe('https://test-subdomain.fscdn.eppo.cloud/api/data');
5961
});
6062

6163
it('should prefer custom baseUrl over SDK token subdomain', () => {
@@ -66,20 +68,21 @@ describe('ApiEndpoints', () => {
6668
baseUrl: customBaseUrl,
6769
sdkToken: new EnhancedSdkToken(sdkToken),
6870
});
69-
expect(endpoints.getEffectiveBaseUrl()).toBe(customBaseUrl);
71+
72+
expect(endpoints.endpoint('')).toContain(customBaseUrl);
7073
});
7174

7275
it('should fallback to DEFAULT_BASE_URL when SDK token has no subdomain', () => {
7376
// This token has no cs parameter
7477
const sdkToken = 'abc.ZWg9ZXZlbnQtaG9zdG5hbWU=';
7578
const endpoints = new ApiEndpoints({ sdkToken: new EnhancedSdkToken(sdkToken) });
76-
expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL);
79+
expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy();
7780
});
7881

7982
it('should fallback to DEFAULT_BASE_URL when SDK token is invalid', () => {
8083
const invalidToken = new EnhancedSdkToken('invalid-token');
8184
const endpoints = new ApiEndpoints({ sdkToken: invalidToken });
82-
expect(endpoints.getEffectiveBaseUrl()).toBe(DEFAULT_BASE_URL);
85+
expect(endpoints.endpoint('').startsWith(DEFAULT_BASE_URL)).toBeTruthy();
8386
});
8487
});
8588

@@ -133,6 +136,37 @@ describe('ApiEndpoints', () => {
133136
});
134137
});
135138

139+
describe('Event Url generation', () => {
140+
const hostnameToken = new EnhancedSdkToken(
141+
'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk',
142+
);
143+
const mockedToken = td.object<EnhancedSdkToken>();
144+
beforeAll(() => {
145+
td.when(mockedToken.isValid()).thenReturn(true);
146+
});
147+
148+
it('should decode the event ingestion hostname from the SDK key', () => {
149+
const endpoints = new ApiEndpoints({ sdkToken: hostnameToken });
150+
const hostname = endpoints.eventIngestionEndpoint();
151+
expect(hostname).toEqual('https://123456.e.testing.eppo.cloud/v0/i');
152+
});
153+
154+
it('should decode strings with non URL-safe characters', () => {
155+
// this is not a really valid ingestion URL, but it's useful for testing the decoder
156+
td.when(mockedToken.getEventIngestionHostname()).thenReturn('12 3456/.e.testing.eppo.cloud');
157+
const endpoints = new ApiEndpoints({ sdkToken: mockedToken });
158+
const hostname = endpoints.eventIngestionEndpoint();
159+
expect(hostname).toEqual('https://12 3456/.e.testing.eppo.cloud/v0/i');
160+
});
161+
162+
it("should return null if the SDK key doesn't contain the event ingestion hostname", () => {
163+
td.when(mockedToken.isValid()).thenReturn(false);
164+
const endpoints = new ApiEndpoints({ sdkToken: mockedToken });
165+
const hostname = endpoints.eventIngestionEndpoint();
166+
expect(hostname).toBeNull();
167+
});
168+
});
169+
136170
describe('Query parameter handling', () => {
137171
it('should append query parameters to endpoint URLs', () => {
138172
const queryParams = { apiKey: 'test-key', sdkName: 'js-sdk', sdkVersion: '1.0.0' };
@@ -161,3 +195,110 @@ describe('ApiEndpoints', () => {
161195
});
162196
});
163197
});
198+
199+
describe('ApiEndpoints - Additional Tests', () => {
200+
describe('URL normalization', () => {
201+
it('should preserve different protocol types', () => {
202+
// We can test this indirectly through the endpoint method
203+
const httpEndpoints = new ApiEndpoints({ baseUrl: 'http://example.com' });
204+
const httpsEndpoints = new ApiEndpoints({ baseUrl: 'https://example.com' });
205+
const protocolRelativeEndpoints = new ApiEndpoints({ baseUrl: '//example.com' });
206+
207+
expect(httpEndpoints.endpoint('test')).toEqual('http://example.com/test');
208+
expect(httpsEndpoints.endpoint('test')).toEqual('https://example.com/test');
209+
expect(protocolRelativeEndpoints.endpoint('test')).toEqual('//example.com/test');
210+
});
211+
212+
it('should add https:// to URLs without protocols', () => {
213+
const endpoints = new ApiEndpoints({ baseUrl: 'example.com' });
214+
expect(endpoints.endpoint('test')).toEqual('https://example.com/test');
215+
});
216+
217+
it('should handle multiple slashes', () => {
218+
const endpoints = new ApiEndpoints({ baseUrl: 'example.com/' });
219+
expect(endpoints.endpoint('/test')).toEqual('https://example.com/test');
220+
});
221+
});
222+
223+
describe('Subdomain handling', () => {
224+
it('should correctly integrate subdomain with base URLs containing paths', () => {
225+
const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain
226+
const endpoints = new ApiEndpoints({
227+
sdkToken,
228+
defaultUrl: 'example.com/api/v2',
229+
});
230+
231+
expect(endpoints.endpoint('')).toContain('https://test-subdomain.example.com/api/v2');
232+
});
233+
234+
it('should handle subdomains with special characters', () => {
235+
// Encode a token with cs=test-sub.domain-special
236+
const sdkToken = new EnhancedSdkToken('abc.Y3M9dGVzdC1zdWIuZG9tYWluLXNwZWNpYWw=');
237+
const endpoints = new ApiEndpoints({ sdkToken });
238+
239+
// The implementation should handle this correctly, but this is what we'd expect
240+
expect(endpoints.endpoint('')).toContain('test-sub.domain-special');
241+
});
242+
});
243+
244+
describe('Event ingestion endpoint', () => {
245+
it('should use subdomain with DEFAULT_EVENT_DOMAIN when hostname is not available', () => {
246+
// Create a mock token with only a subdomain
247+
const mockToken = {
248+
isValid: () => true,
249+
getEventIngestionHostname: () => null,
250+
getSubdomain: () => 'test-subdomain',
251+
} as EnhancedSdkToken;
252+
253+
const endpoints = new ApiEndpoints({ sdkToken: mockToken });
254+
expect(endpoints.eventIngestionEndpoint()).toEqual(
255+
`https://test-subdomain.${DEFAULT_EVENT_DOMAIN}/v0/i`,
256+
);
257+
});
258+
259+
it('should prioritize hostname over subdomain if both are available', () => {
260+
// Create a mock token with both hostname and subdomain
261+
const mockToken = {
262+
isValid: () => true,
263+
getEventIngestionHostname: () => 'event-host.example.com',
264+
getSubdomain: () => 'test-subdomain',
265+
} as EnhancedSdkToken;
266+
267+
const endpoints = new ApiEndpoints({ sdkToken: mockToken });
268+
expect(endpoints.eventIngestionEndpoint()).toEqual('https://event-host.example.com/v0/i');
269+
});
270+
271+
it('should return null when token is valid but no hostname or subdomain is available', () => {
272+
// Create a mock token with neither hostname nor subdomain
273+
const mockToken = {
274+
isValid: () => true,
275+
getEventIngestionHostname: () => null,
276+
getSubdomain: () => null,
277+
} as EnhancedSdkToken;
278+
279+
const endpoints = new ApiEndpoints({ sdkToken: mockToken });
280+
expect(endpoints.eventIngestionEndpoint()).toBeNull();
281+
});
282+
});
283+
284+
describe('Edge cases and error handling', () => {
285+
it('should handle extremely long subdomains', () => {
286+
const longSubdomain = 'a'.repeat(100);
287+
const mockToken = {
288+
isValid: () => true,
289+
getSubdomain: () => longSubdomain,
290+
} as EnhancedSdkToken;
291+
292+
const endpoints = new ApiEndpoints({ sdkToken: mockToken });
293+
expect(endpoints.endpoint('')).toContain(longSubdomain);
294+
});
295+
296+
it('should handle unusual base URL formats', () => {
297+
const endpoints = new ApiEndpoints({
298+
baseUrl: 'https://@:example.com:8080/path?query=value#fragment',
299+
});
300+
// The exact handling will depend on implementation details, but it shouldn't throw
301+
expect(() => endpoints.endpoint('test')).not.toThrow();
302+
});
303+
});
304+
});

src/api-endpoints.ts

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
import { BANDIT_ENDPOINT, BASE_URL, PRECOMPUTED_FLAGS_ENDPOINT, UFC_ENDPOINT } from './constants';
1+
import {
2+
BANDIT_ENDPOINT,
3+
BASE_URL,
4+
DEFAULT_EVENT_DOMAIN,
5+
PRECOMPUTED_FLAGS_ENDPOINT,
6+
UFC_ENDPOINT,
7+
} from './constants';
28
import EnhancedSdkToken from './enhanced-sdk-token';
39
import { IQueryParams, IQueryParamsWithSubject } from './http-client';
410

11+
const EVENT_ENDPOINT = 'v0/i';
12+
513
interface IApiEndpointsParams {
614
queryParams?: IQueryParams | IQueryParamsWithSubject;
715
baseUrl?: string;
816
defaultUrl: string;
917
sdkToken?: EnhancedSdkToken;
1018
}
1119

12-
/** Utility class for constructing an Eppo API endpoint URL given a provided baseUrl and query parameters */
20+
/**
21+
* Utility class for constructing Eppo API endpoint URLs
22+
*/
1323
export default class ApiEndpoints {
1424
private readonly sdkToken: EnhancedSdkToken | null;
1525
private readonly _effectiveBaseUrl: string;
@@ -18,12 +28,20 @@ export default class ApiEndpoints {
1828
constructor(params: Partial<IApiEndpointsParams>) {
1929
this.params = Object.assign({}, { defaultUrl: BASE_URL }, params);
2030
this.sdkToken = params.sdkToken ?? null;
31+
this._effectiveBaseUrl = this.determineBaseUrl();
32+
}
2133

22-
// this.params.baseUrl =
23-
// params.baseUrl && params.baseUrl !== DEFAULT_BASE_URL ? params.baseUrl : DEFAULT_URL;
34+
/**
35+
* Normalizes a URL by ensuring proper protocol and removing trailing slashes
36+
*/
37+
private normalizeUrl(url: string, protocol = 'https://'): string {
38+
const protocolMatch = url.match(/^(https?:\/\/|\/\/)/i);
2439

25-
// Set the effective base URL.
26-
this._effectiveBaseUrl = this.determineBaseUrl();
40+
if (protocolMatch) {
41+
return url;
42+
} else {
43+
return `${protocol}${url}`;
44+
}
2745
}
2846

2947
/**
@@ -32,78 +50,89 @@ export default class ApiEndpoints {
3250
* 2. If the api key contains an encoded customer-specific subdomain, use it with DEFAULT_DOMAIN
3351
* 3. Otherwise, fall back to DEFAULT_BASE_URL
3452
*/
53+
private joinUrlParts(...parts: string[]): string {
54+
return parts
55+
.map((part) => part.trim())
56+
.map((part, i) => {
57+
// For first part, remove trailing slash
58+
if (i === 0) return part.replace(/\/+$/, '');
59+
// For other parts, remove leading and trailing slashes
60+
return part.replace(/^\/+|\/+$/g, '');
61+
})
62+
.join('/');
63+
}
64+
65+
/**
66+
* Determine the effective base URL based on the constructor parameters
67+
*/
3568
private determineBaseUrl(): string {
3669
// If baseUrl is explicitly provided and different from default, use it
3770
if (this.params.baseUrl && this.params.baseUrl !== this.params.defaultUrl) {
38-
return this.params.baseUrl;
71+
return this.normalizeUrl(this.params.baseUrl);
3972
}
4073

41-
// If there's an enhanced SDK token with a subdomain, it will be prepended in the buildUrl method.
74+
// If there's a valid SDK token with a subdomain, use it
4275
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-
}
76+
if (subdomain && this.sdkToken?.isValid()) {
77+
// Extract the domain part without protocol
78+
const defaultUrl = this.params.defaultUrl;
79+
const domainPart = defaultUrl.replace(/^(https?:\/\/|\/\/)/, '');
80+
return this.normalizeUrl(`${subdomain}.${domainPart}`);
81+
}
5282

53-
/**
54-
* Returns the base URL being used for the UFC and bandit endpoints
55-
*/
56-
getEffectiveBaseUrl(): string {
57-
return this._effectiveBaseUrl;
83+
// Fall back to default URL
84+
return this.normalizeUrl(this.params.defaultUrl);
5885
}
5986

6087
/**
6188
* Creates an endpoint URL with the specified resource path and query parameters
6289
*/
6390
endpoint(resource: string): string {
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}`;
91+
const url = this.joinUrlParts(this._effectiveBaseUrl, resource);
7092

7193
const queryParams = this.params.queryParams;
7294
if (!queryParams) {
73-
return endpointUrl;
95+
return url;
7496
}
7597

7698
const urlSearchParams = new URLSearchParams();
7799
Object.entries(queryParams).forEach(([key, value]) => urlSearchParams.append(key, value));
78100

79-
return `${endpointUrl}?${urlSearchParams}`;
101+
return `${url}?${urlSearchParams}`;
80102
}
81103

82-
/**
83-
* Returns the URL for the UFC endpoint
84-
*/
85104
ufcEndpoint(): string {
86105
return this.endpoint(UFC_ENDPOINT);
87106
}
88107

89-
/**
90-
* Returns the URL for the bandit parameters endpoint
91-
*/
92108
banditParametersEndpoint(): string {
93109
return this.endpoint(BANDIT_ENDPOINT);
94110
}
95111

96-
/**
97-
* Returns the URL for the precomputed flags endpoint
98-
*/
99112
precomputedFlagsEndpoint(): string {
100113
return this.endpoint(PRECOMPUTED_FLAGS_ENDPOINT);
101114
}
102115

103-
private stripProtocol(url: string) {
104-
return ApiEndpoints.URL_PROTOCOLS.reduce((prev, cur) => {
105-
return prev.replace(cur, '');
106-
}, url);
116+
eventIngestionEndpoint(): string | null {
117+
if (!this.sdkToken?.isValid()) return null;
118+
119+
const hostname = this.sdkToken.getEventIngestionHostname();
120+
const subdomain = this.sdkToken.getSubdomain();
121+
122+
if (!hostname && !subdomain) return null;
123+
124+
// If we have a hostname from the token, use it directly
125+
if (hostname) {
126+
return this.normalizeUrl(this.joinUrlParts(hostname, EVENT_ENDPOINT));
127+
}
128+
129+
// Otherwise use subdomain with default event domain
130+
if (subdomain) {
131+
return this.normalizeUrl(
132+
this.joinUrlParts(`${subdomain}.${DEFAULT_EVENT_DOMAIN}`, EVENT_ENDPOINT),
133+
);
134+
}
135+
136+
return null;
107137
}
108-
public static readonly URL_PROTOCOLS = ['http://', 'https://', '//'];
109138
}

0 commit comments

Comments
 (0)