Skip to content

Commit dd5a342

Browse files
authored
feat: Expose new ApiEndpoints class to return API endpoint URL (#73)
* feat: Expose API to return API endpoint URL * export ApiEndpoints * jsdoc * bump version * do not export ApiEndpoints
1 parent 3a071ef commit dd5a342

File tree

8 files changed

+98
-62
lines changed

8 files changed

+98
-62
lines changed

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": "3.1.0",
3+
"version": "3.2.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [

src/api-endpoint.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import ApiEndpoints from './api-endpoints';
2+
3+
describe('ApiEndpoints', () => {
4+
it('should append query parameters to the URL', () => {
5+
const apiEndpoints = new ApiEndpoints('http://api.example.com', {
6+
apiKey: '12345',
7+
sdkVersion: 'foobar',
8+
sdkName: 'ExampleSDK',
9+
});
10+
expect(apiEndpoints.endpoint('/data').toString()).toEqual(
11+
'http://api.example.com/data?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
12+
);
13+
expect(apiEndpoints.ufcEndpoint().toString()).toEqual(
14+
'http://api.example.com/flag-config/v1/config?apiKey=12345&sdkVersion=foobar&sdkName=ExampleSDK',
15+
);
16+
});
17+
});

src/api-endpoints.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ISdkParams } from './http-client';
2+
3+
const UFC_ENDPOINT = '/flag-config/v1/config';
4+
5+
/** Utility class for constructing an Eppo API endpoint URL given a provided baseUrl and query parameters */
6+
export default class ApiEndpoints {
7+
constructor(private readonly baseUrl: string, private readonly queryParams: ISdkParams) {}
8+
9+
endpoint(resource: string): URL {
10+
const url = new URL(this.baseUrl + resource);
11+
Object.entries(this.queryParams).forEach(([key, value]) => url.searchParams.append(key, value));
12+
return url;
13+
}
14+
15+
ufcEndpoint(): URL {
16+
return this.endpoint(UFC_ENDPOINT);
17+
}
18+
}

src/client/eppo-client.spec.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
readMockUFCResponse,
1111
validateTestAssignments,
1212
} from '../../test/testHelpers';
13+
import ApiEndpoints from '../api-endpoints';
1314
import { IAssignmentLogger } from '../assignment-logger';
1415
import { IConfigurationStore } from '../configuration-store/configuration-store';
1516
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
@@ -21,15 +22,12 @@ import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
2122
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
2223

2324
export async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
24-
const httpClient = new FetchHttpClient(
25-
'http://127.0.0.1:4000',
26-
{
27-
apiKey: 'dummy',
28-
sdkName: 'js-client-sdk-common',
29-
sdkVersion: '1.0.0',
30-
},
31-
1000,
32-
);
25+
const apiEndpoints = new ApiEndpoints('http://127.0.0.1:4000', {
26+
apiKey: 'dummy',
27+
sdkName: 'js-client-sdk-common',
28+
sdkVersion: '1.0.0',
29+
});
30+
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
3331
const configurationRequestor = new FlagConfigurationRequestor(configurationStore, httpClient);
3432
await configurationRequestor.fetchAndStoreConfigurations();
3533
}

src/client/eppo-client.ts

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ApiEndpoints from '../api-endpoints';
12
import { logger } from '../application-logger';
23
import {
34
AssignmentCache,
@@ -165,7 +166,6 @@ export default class EppoClient implements IEppoClient {
165166
private queuedEvents: IAssignmentEvent[] = [];
166167
private assignmentLogger: IAssignmentLogger | undefined;
167168
private isGracefulFailureMode = true;
168-
private isObfuscated = false;
169169
private assignmentCache: AssignmentCache<Cacheable> | undefined;
170170
private configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>;
171171
private configurationRequestParameters: FlagConfigurationRequestParameters | undefined;
@@ -175,12 +175,11 @@ export default class EppoClient implements IEppoClient {
175175
constructor(
176176
configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>,
177177
configurationRequestParameters?: FlagConfigurationRequestParameters,
178-
obfuscated = false,
178+
private readonly isObfuscated = false,
179179
) {
180180
this.evaluator = new Evaluator();
181181
this.configurationStore = configurationStore;
182182
this.configurationRequestParameters = configurationRequestParameters;
183-
this.isObfuscated = obfuscated;
184183
}
185184

186185
public setConfigurationRequestParameters(
@@ -212,17 +211,22 @@ export default class EppoClient implements IEppoClient {
212211
);
213212
return;
214213
}
215-
216-
// todo: consider injecting the IHttpClient interface
217-
const httpClient = new FetchHttpClient(
218-
this.configurationRequestParameters.baseUrl || DEFAULT_BASE_URL,
219-
{
220-
apiKey: this.configurationRequestParameters.apiKey,
221-
sdkName: this.configurationRequestParameters.sdkName,
222-
sdkVersion: this.configurationRequestParameters.sdkVersion,
223-
},
224-
this.configurationRequestParameters.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
225-
);
214+
const {
215+
apiKey,
216+
sdkName,
217+
sdkVersion,
218+
baseUrl = DEFAULT_BASE_URL,
219+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
220+
numInitialRequestRetries = DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
221+
numPollRequestRetries = DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
222+
pollAfterSuccessfulInitialization = false,
223+
pollAfterFailedInitialization = false,
224+
throwOnFailedInitialization = false,
225+
skipInitialPoll = false,
226+
} = this.configurationRequestParameters;
227+
// todo: Inject the chain of dependencies below
228+
const apiEndpoints = new ApiEndpoints(baseUrl, { apiKey, sdkName, sdkVersion });
229+
const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs);
226230
const configurationRequestor = new FlagConfigurationRequestor(
227231
this.configurationStore,
228232
httpClient,
@@ -232,19 +236,12 @@ export default class EppoClient implements IEppoClient {
232236
POLL_INTERVAL_MS,
233237
configurationRequestor.fetchAndStoreConfigurations.bind(configurationRequestor),
234238
{
235-
maxStartRetries:
236-
this.configurationRequestParameters.numInitialRequestRetries ??
237-
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
238-
maxPollRetries:
239-
this.configurationRequestParameters.numPollRequestRetries ??
240-
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
241-
pollAfterSuccessfulStart:
242-
this.configurationRequestParameters.pollAfterSuccessfulInitialization ?? false,
243-
pollAfterFailedStart:
244-
this.configurationRequestParameters.pollAfterFailedInitialization ?? false,
245-
errorOnFailedStart:
246-
this.configurationRequestParameters.throwOnFailedInitialization ?? false,
247-
skipInitialPoll: this.configurationRequestParameters.skipInitialPoll ?? false,
239+
maxStartRetries: numInitialRequestRetries,
240+
maxPollRetries: numPollRequestRetries,
241+
pollAfterSuccessfulStart: pollAfterSuccessfulInitialization,
242+
pollAfterFailedStart: pollAfterFailedInitialization,
243+
errorOnFailedStart: throwOnFailedInitialization,
244+
skipInitialPoll: skipInitialPoll,
248245
},
249246
);
250247

@@ -390,11 +387,10 @@ export default class EppoClient implements IEppoClient {
390387
* Note: This method is experimental and may change in future versions.
391388
* Please only use for debugging purposes, and not in production.
392389
*
393-
* @param subjectKey The subject key
394390
* @param flagKey The flag key
391+
* @param subjectKey The subject key
395392
* @param subjectAttributes The subject attributes
396393
* @param expectedVariationType The expected variation type
397-
* @param obfuscated Whether the flag key is obfuscated
398394
* @returns A detailed return of assignment for a particular subject and flag
399395
*/
400396
public getAssignmentDetail(

src/flag-configuration-requestor.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,15 @@ import { IConfigurationStore } from './configuration-store/configuration-store';
22
import { IHttpClient } from './http-client';
33
import { Flag } from './interfaces';
44

5-
const UFC_ENDPOINT = '/flag-config/v1/config';
6-
7-
interface IUniversalFlagConfig {
8-
flags: Record<string, Flag>;
9-
}
10-
5+
// Requests AND stores flag configurations
116
export default class FlagConfigurationRequestor {
127
constructor(
13-
private configurationStore: IConfigurationStore<Flag>,
14-
private httpClient: IHttpClient,
8+
private readonly configurationStore: IConfigurationStore<Flag>,
9+
private readonly httpClient: IHttpClient,
1510
) {}
1611

1712
async fetchAndStoreConfigurations(): Promise<Record<string, Flag>> {
18-
const responseData = await this.httpClient.get<IUniversalFlagConfig>(UFC_ENDPOINT);
13+
const responseData = await this.httpClient.getUniversalFlagConfiguration();
1914
if (!responseData) {
2015
return {};
2116
}

src/http-client.spec.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ApiEndpoints from './api-endpoints';
12
import FetchHttpClient, { HttpRequestError, ISdkParams } from './http-client';
23

34
describe('FetchHttpClient', () => {
@@ -8,11 +9,10 @@ describe('FetchHttpClient', () => {
89
sdkName: 'ExampleSDK',
910
};
1011
const timeout = 5000; // 5 seconds
11-
12-
let httpClient: FetchHttpClient;
12+
const apiEndpoints = new ApiEndpoints(baseUrl, sdkParams);
13+
const httpClient = new FetchHttpClient(apiEndpoints, timeout);
1314

1415
beforeEach(() => {
15-
httpClient = new FetchHttpClient(baseUrl, sdkParams, timeout);
1616
global.fetch = jest.fn();
1717
});
1818

@@ -30,7 +30,7 @@ describe('FetchHttpClient', () => {
3030
(global.fetch as jest.Mock).mockImplementation(() => mockFetchPromise);
3131

3232
const resource = '/data';
33-
const result = await httpClient.get(resource);
33+
const result = await httpClient.rawGet(apiEndpoints.endpoint(resource));
3434

3535
expect(global.fetch).toHaveBeenCalledTimes(1);
3636
expect(global.fetch).toHaveBeenCalledWith(
@@ -49,8 +49,9 @@ describe('FetchHttpClient', () => {
4949
(global.fetch as jest.Mock).mockImplementation(() => mockFetchPromise);
5050

5151
const resource = '/data';
52-
await expect(httpClient.get(resource)).rejects.toThrow(HttpRequestError);
53-
await expect(httpClient.get(resource)).rejects.toEqual(
52+
const url = apiEndpoints.endpoint(resource);
53+
await expect(httpClient.rawGet(url)).rejects.toThrow(HttpRequestError);
54+
await expect(httpClient.rawGet(url)).rejects.toEqual(
5455
new HttpRequestError('Failed to fetch data', 404),
5556
);
5657
});
@@ -71,7 +72,8 @@ describe('FetchHttpClient', () => {
7172
);
7273

7374
const resource = '/data';
74-
const getPromise = httpClient.get(resource);
75+
const url = apiEndpoints.endpoint(resource);
76+
const getPromise = httpClient.rawGet(url);
7577

7678
// Immediately advance the timers by 10 seconds to simulate the timeout
7779
jest.advanceTimersByTime(10000);

src/http-client.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import ApiEndpoints from './api-endpoints';
2+
import { Flag } from './interfaces';
3+
14
export interface ISdkParams {
25
apiKey: string;
36
sdkVersion: string;
@@ -13,31 +16,38 @@ export class HttpRequestError extends Error {
1316
}
1417
}
1518

19+
export interface IUniversalFlagConfig {
20+
flags: Record<string, Flag>;
21+
}
22+
1623
export interface IHttpClient {
17-
get<T>(resource: string): Promise<T | undefined>;
24+
getUniversalFlagConfiguration(): Promise<IUniversalFlagConfig | undefined>;
25+
rawGet<T>(url: URL): Promise<T | undefined>;
1826
}
1927

2028
export default class FetchHttpClient implements IHttpClient {
21-
constructor(private baseUrl: string, private sdkParams: ISdkParams, private timeout: number) {}
29+
constructor(private readonly apiEndpoints: ApiEndpoints, private readonly timeout: number) {}
2230

23-
async get<T>(resource: string): Promise<T | undefined> {
24-
const url = new URL(this.baseUrl + resource);
25-
Object.entries(this.sdkParams).forEach(([key, value]) => url.searchParams.append(key, value));
31+
async getUniversalFlagConfiguration(): Promise<IUniversalFlagConfig | undefined> {
32+
const url = this.apiEndpoints.ufcEndpoint();
33+
return await this.rawGet<IUniversalFlagConfig>(url);
34+
}
2635

36+
async rawGet<T>(url: URL): Promise<T | undefined> {
2737
try {
2838
// Canonical implementation of abortable fetch for interrupting when request takes longer than desired.
2939
// https://developer.chrome.com/blog/abortable-fetch/#reacting_to_an_aborted_fetch
3040
const controller = new AbortController();
3141
const signal = controller.signal;
3242
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
33-
const response = await fetch(url.toString(), { signal: signal });
43+
const response = await fetch(url.toString(), { signal });
3444
// Clear timeout when response is received within the budget.
3545
clearTimeout(timeoutId);
3646

3747
if (!response.ok) {
3848
throw new HttpRequestError('Failed to fetch data', response.status);
3949
}
40-
return response.json() as Promise<T>;
50+
return await response.json();
4151
} catch (error) {
4252
if (error.name === 'AbortError') {
4353
throw new HttpRequestError('Request timed out', 408, error);

0 commit comments

Comments
 (0)