Skip to content

Commit 5edebab

Browse files
authored
feat: use encoded subdomain for endpoints (#263)
* move event URL into api endpoints * Expand, move, and rename SdkKeyDecoder to produce customer subdomain * v4.15.0
1 parent 246c2ae commit 5edebab

15 files changed

+593
-104
lines changed

.github/pull_request_template.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Fixes: #__issue__
1010
## Description
1111
[//]: # (Describe your changes in detail)
1212

13+
## How has this been documented?
14+
[//]: # (Please describe how you documented the developer impact of your changes; link to PRs or issues or explan why no documentation changes are required)
15+
1316
## How has this been tested?
1417
[//]: # (Please describe in detail how you tested your changes)
1518

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.14.4",
3+
"version": "4.15.0",
44
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
55
"main": "dist/index.js",
66
"files": [

src/api-endpoint.spec.ts

Lines changed: 235 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,249 @@
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';
5+
import SdkTokenDecoder from './sdk-token-decoder';
36

47
describe('ApiEndpoints', () => {
5-
it('should append query parameters to the URL', () => {
6-
const apiEndpoints = new ApiEndpoints({
7-
baseUrl: 'http://api.example.com',
8-
queryParams: {
9-
apiKey: '12345',
10-
sdkVersion: 'foobar',
11-
sdkName: 'ExampleSDK',
8+
describe('Query parameters', () => {
9+
describe('should correctly handle query parameters in various scenarios', () => {
10+
const testCases = [
11+
{
12+
name: 'with custom base URL and query params',
13+
params: {
14+
baseUrl: 'http://api.example.com',
15+
queryParams: {
16+
apiKey: '12345',
17+
sdkVersion: 'foobar',
18+
sdkName: 'ExampleSDK',
19+
},
20+
},
21+
expected:
22+
'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
23+
},
24+
{
25+
name: 'with default base URL and query params',
26+
params: {
27+
queryParams: {
28+
apiKey: '12345',
29+
sdkVersion: 'foobar',
30+
sdkName: 'ExampleSDK',
31+
},
32+
},
33+
expected: `${DEFAULT_BASE_URL}/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK`,
34+
},
35+
{
36+
name: 'without query params',
37+
params: {},
38+
expected: `${DEFAULT_BASE_URL}/flag-config/v1/config`,
39+
},
40+
{
41+
name: 'with special characters in query params',
42+
params: {
43+
queryParams: {
44+
apiKey: 'test-key',
45+
sdkName: 'value with spaces',
46+
sdkVersion: 'a+b=c&d',
47+
},
48+
},
49+
expected:
50+
'https://fscdn.eppo.cloud/api/flag-config/v1/config?apiKey=test-key&sdkName=value+with+spaces&sdkVersion=a%2Bb%3Dc%26d',
51+
},
52+
];
53+
54+
testCases.forEach(({ name, params, expected }) => {
55+
it(`${name}`, () => {
56+
const apiEndpoints = new ApiEndpoints(params);
57+
const result = apiEndpoints.ufcEndpoint();
58+
59+
expect(result).toEqual(expected);
60+
});
61+
});
62+
});
63+
});
64+
65+
describe('Base URL determination', () => {
66+
const testCases = [
67+
{
68+
name: 'should use custom baseUrl when provided',
69+
params: { baseUrl: 'https://custom-domain.com' },
70+
expected: 'https://custom-domain.com/assignments',
71+
},
72+
{
73+
name: 'should use subdomain from SDK token when valid',
74+
params: { sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4=') },
75+
expected: 'https://test-subdomain.fscdn.eppo.cloud/api/assignments',
76+
},
77+
{
78+
name: 'should prefer custom baseUrl over SDK token subdomain',
79+
params: {
80+
baseUrl: 'https://custom-domain.com',
81+
sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='),
82+
},
83+
expected: 'https://custom-domain.com/assignments',
84+
},
85+
{
86+
name: 'should not allow custom baseUrl to be the default base url',
87+
params: {
88+
baseUrl: DEFAULT_BASE_URL,
89+
sdkTokenDecoder: new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='),
90+
},
91+
expected: 'https://test-subdomain.fscdn.eppo.cloud/api/assignments',
92+
},
93+
{
94+
name: 'should fallback to DEFAULT_BASE_URL when SDK token has no subdomain',
95+
params: { sdkTokenDecoder: new SdkTokenDecoder('abc.ZWg9ZXZlbnQtaG9zdG5hbWU=') },
96+
expected: 'https://fscdn.eppo.cloud/api/assignments',
1297
},
98+
{
99+
name: 'should fallback to DEFAULT_BASE_URL when SDK token has nothing encoded',
100+
params: { sdkTokenDecoder: new SdkTokenDecoder('invalid-token') },
101+
expected: 'https://fscdn.eppo.cloud/api/assignments',
102+
},
103+
];
104+
105+
testCases.forEach(({ name, params, expected }) => {
106+
it(name, () => {
107+
const endpoints = new ApiEndpoints(params);
108+
const result = endpoints.precomputedFlagsEndpoint();
109+
110+
expect(result).toBe(expected);
111+
});
13112
});
14-
expect(apiEndpoints.endpoint('/data').toString()).toEqual(
15-
'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
16-
);
17-
expect(apiEndpoints.ufcEndpoint().toString()).toEqual(
18-
'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
19-
);
20113
});
21114

22-
it('should use default base URL if not provided', () => {
23-
const apiEndpoints = new ApiEndpoints({
24-
queryParams: {
25-
apiKey: '12345',
26-
sdkVersion: 'foobar',
27-
sdkName: 'ExampleSDK',
115+
describe('Endpoint URL construction', () => {
116+
const sdkTokenDecoder = new SdkTokenDecoder('abc.Y3M9dGVzdC1zdWJkb21haW4='); // cs=test-subdomain
117+
118+
const endpointTestCases = [
119+
{
120+
name: 'UFC endpoint with subdomain',
121+
factory: (api: ApiEndpoints) => api.ufcEndpoint(),
122+
expected: 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/config',
123+
},
124+
{
125+
name: 'bandit parameters endpoint with subdomain',
126+
factory: (api: ApiEndpoints) => api.banditParametersEndpoint(),
127+
expected: 'https://test-subdomain.fscdn.eppo.cloud/api/flag-config/v1/bandits',
28128
},
129+
];
130+
131+
endpointTestCases.forEach(({ name, factory, expected }) => {
132+
it(name, () => {
133+
const endpoints = new ApiEndpoints({ sdkTokenDecoder: sdkTokenDecoder });
134+
const result = factory(endpoints);
135+
expect(result).toBe(expected);
136+
});
29137
});
30-
expect(apiEndpoints.endpoint('/data').toString()).toEqual(
31-
`${DEFAULT_BASE_URL}/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK`,
32-
);
33-
expect(apiEndpoints.ufcEndpoint().toString()).toEqual(
34-
`${DEFAULT_BASE_URL}/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK`,
35-
);
36138
});
37139

38-
it('should not append query parameters if not provided', () => {
39-
const apiEndpoints = new ApiEndpoints({});
40-
expect(apiEndpoints.endpoint('/data').toString()).toEqual(`${DEFAULT_BASE_URL}/data`);
41-
expect(apiEndpoints.ufcEndpoint().toString()).toEqual(
42-
`${DEFAULT_BASE_URL}/flag-config/v1/config`,
140+
describe('Event ingestion URL', () => {
141+
const hostnameToken = new SdkTokenDecoder(
142+
'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk',
43143
);
144+
let mockedDecoder: SdkTokenDecoder;
145+
146+
beforeEach(() => {
147+
mockedDecoder = td.object<SdkTokenDecoder>();
148+
td.when(mockedDecoder.isValid()).thenReturn(true);
149+
});
150+
151+
const eventUrlTestCases = [
152+
{
153+
name: 'should decode the event ingestion hostname from the SDK key',
154+
setupDecoder: () => hostnameToken,
155+
expected: 'https://123456.e.testing.eppo.cloud/v0/i',
156+
},
157+
{
158+
name: 'should decode strings with non URL-safe characters',
159+
setupDecoder: () => {
160+
td.when(mockedDecoder.getEventIngestionHostname()).thenReturn(
161+
'12 3456/.e.testing.eppo.cloud',
162+
);
163+
return mockedDecoder;
164+
},
165+
expected: 'https://12 3456/.e.testing.eppo.cloud/v0/i',
166+
},
167+
{
168+
name: 'should return null if the SDK key is invalid',
169+
setupDecoder: () => {
170+
td.when(mockedDecoder.isValid()).thenReturn(false);
171+
return mockedDecoder;
172+
},
173+
expected: null,
174+
},
175+
{
176+
name: 'should use subdomain with DEFAULT_EVENT_DOMAIN when hostname is not available',
177+
setupDecoder: () => {
178+
td.when(mockedDecoder.getEventIngestionHostname()).thenReturn(null);
179+
td.when(mockedDecoder.getSubdomain()).thenReturn('test-subdomain');
180+
return mockedDecoder;
181+
},
182+
expected: `https://test-subdomain.${DEFAULT_EVENT_DOMAIN}/v0/i`,
183+
},
184+
{
185+
name: 'should prioritize hostname over subdomain if both are available',
186+
setupDecoder: () => {
187+
td.when(mockedDecoder.getEventIngestionHostname()).thenReturn('event-host.example.com');
188+
td.when(mockedDecoder.getSubdomain()).thenReturn('test-subdomain');
189+
return mockedDecoder;
190+
},
191+
expected: 'https://event-host.example.com/v0/i',
192+
},
193+
{
194+
name: 'should return null when token is valid but no hostname or subdomain is available',
195+
setupDecoder: () => {
196+
td.when(mockedDecoder.getEventIngestionHostname()).thenReturn(null);
197+
td.when(mockedDecoder.getSubdomain()).thenReturn(null);
198+
return mockedDecoder;
199+
},
200+
expected: null,
201+
},
202+
];
203+
204+
eventUrlTestCases.forEach(({ name, setupDecoder, expected }) => {
205+
it(name, () => {
206+
const decoder = setupDecoder();
207+
const endpoints = new ApiEndpoints({ sdkTokenDecoder: decoder });
208+
expect(endpoints.eventIngestionEndpoint()).toEqual(expected);
209+
});
210+
});
211+
});
212+
213+
describe('URL normalization', () => {
214+
const urlNormalizationTestCases = [
215+
{
216+
name: 'preserve http:// protocol',
217+
baseUrl: 'http://example.com',
218+
expected: 'http://example.com/flag-config/v1/config',
219+
},
220+
{
221+
name: 'preserve https:// protocol',
222+
baseUrl: 'https://example.com',
223+
expected: 'https://example.com/flag-config/v1/config',
224+
},
225+
{
226+
name: 'preserve // protocol',
227+
baseUrl: '//example.com',
228+
expected: '//example.com/flag-config/v1/config',
229+
},
230+
{
231+
name: 'add https:// to URLs without protocols',
232+
baseUrl: 'example.com',
233+
expected: 'https://example.com/flag-config/v1/config',
234+
},
235+
{
236+
name: 'handle multiple slashes',
237+
baseUrl: 'example.com/',
238+
expected: 'https://example.com/flag-config/v1/config',
239+
},
240+
];
241+
242+
urlNormalizationTestCases.forEach(({ name, baseUrl, expected }) => {
243+
it(`should ${name}`, () => {
244+
const endpoints = new ApiEndpoints({ baseUrl });
245+
expect(endpoints.ufcEndpoint()).toEqual(expected);
246+
});
247+
});
44248
});
45249
});

0 commit comments

Comments
 (0)