diff --git a/package.json b/package.json index 85f0c4f..7605486 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.3.1-alpha.1", + "version": "4.3.1-alpha.2", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index 05e6922..ad24824 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -84,7 +84,9 @@ describe('EppoPrecomputedClient E2E test', () => { }, }); const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const precomputedFlagRequestor = new PrecomputedRequestor(httpClient, storage); + const precomputedFlagRequestor = new PrecomputedRequestor(httpClient, storage, 'subject-key', { + 'attribute-key': 'attribute-value', + }); await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); }); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts index d47784e..7e99fed 100644 --- a/src/client/eppo-precomputed-client.ts +++ b/src/client/eppo-precomputed-client.ts @@ -102,10 +102,15 @@ export default class EppoPrecomputedClient { // todo: Inject the chain of dependencies below const apiEndpoints = new ApiEndpoints({ baseUrl: baseUrl ?? PRECOMPUTED_BASE_URL, - queryParams: { apiKey, sdkName, sdkVersion, subjectKey, subjectAttributes }, + queryParams: { apiKey, sdkName, sdkVersion }, }); const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); - const precomputedRequestor = new PrecomputedRequestor(httpClient, this.precomputedFlagStore); + const precomputedRequestor = new PrecomputedRequestor( + httpClient, + this.precomputedFlagStore, + subjectKey, + subjectAttributes, + ); const pollingCallback = async () => { if (await this.precomputedFlagStore.isExpired()) { diff --git a/src/http-client.ts b/src/http-client.ts index e850912..35f8c97 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -6,7 +6,9 @@ import { Flag, FormatEnum, PrecomputedFlag, + PrecomputedFlagsPayload, } from './interfaces'; +import { Attributes } from './types'; export interface IQueryParams { apiKey: string; @@ -16,7 +18,7 @@ export interface IQueryParams { export interface IQueryParamsWithSubject extends IQueryParams { subjectKey: string; - subjectAttributes: Record; + subjectAttributes: Attributes; } export class HttpRequestError extends Error { @@ -50,8 +52,11 @@ export interface IPrecomputedFlagsResponse { export interface IHttpClient { getUniversalFlagConfiguration(): Promise; getBanditParameters(): Promise; - getPrecomputedFlags(): Promise; + getPrecomputedFlags( + payload: PrecomputedFlagsPayload, + ): Promise; rawGet(url: URL): Promise; + rawPost(url: URL, payload: P): Promise; } export default class FetchHttpClient implements IHttpClient { @@ -67,9 +72,11 @@ export default class FetchHttpClient implements IHttpClient { return await this.rawGet(url); } - async getPrecomputedFlags(): Promise { + async getPrecomputedFlags( + payload: PrecomputedFlagsPayload, + ): Promise { const url = this.apiEndpoints.precomputedFlagsEndpoint(); - return await this.rawGet(url); + return await this.rawPost(url, payload); } async rawGet(url: URL): Promise { @@ -97,4 +104,37 @@ export default class FetchHttpClient implements IHttpClient { throw new HttpRequestError('Network error', 0, error); } } + + async rawPost(url: URL, payload: P): Promise { + try { + const controller = new AbortController(); + const signal = controller.signal; + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal, + }); + + clearTimeout(timeoutId); + + if (!response?.ok) { + const errorBody = await response.text(); + throw new HttpRequestError(errorBody || 'Failed to post data', response?.status); + } + return await response.json(); + } catch (error: any) { + if (error.name === 'AbortError') { + throw new HttpRequestError('Request timed out', 408, error); + } else if (error instanceof HttpRequestError) { + throw error; + } + + throw new HttpRequestError('Network error', 0, error); + } + } } diff --git a/src/interfaces.ts b/src/interfaces.ts index ecbe0d8..3afcb21 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,5 @@ import { Rule } from './rules'; +import { Attributes } from './types'; export enum VariationType { STRING = 'STRING', @@ -155,3 +156,8 @@ export interface PrecomputedFlagsDetails { precomputedFlagsPublishedAt: string; precomputedFlagsEnvironment: Environment; } + +export interface PrecomputedFlagsPayload { + subject_key: string; + subject_attributes: Attributes; +} diff --git a/src/precomputed-requestor.spec.ts b/src/precomputed-requestor.spec.ts index c89d368..1f258a7 100644 --- a/src/precomputed-requestor.spec.ts +++ b/src/precomputed-requestor.spec.ts @@ -3,7 +3,7 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import FetchHttpClient, { IHttpClient } from './http-client'; import { PrecomputedFlag } from './interfaces'; -import ConfigurationRequestor from './precomputed-requestor'; +import PrecomputedFlagRequestor from './precomputed-requestor'; const MOCK_PRECOMPUTED_RESPONSE = { flags: { @@ -34,7 +34,7 @@ const MOCK_PRECOMPUTED_RESPONSE = { describe('PrecomputedRequestor', () => { let precomputedFlagStore: IConfigurationStore; let httpClient: IHttpClient; - let configurationRequestor: ConfigurationRequestor; + let precomputedFlagRequestor: PrecomputedFlagRequestor; let fetchSpy: jest.Mock; beforeEach(() => { @@ -48,7 +48,14 @@ describe('PrecomputedRequestor', () => { }); httpClient = new FetchHttpClient(apiEndpoints, 1000); precomputedFlagStore = new MemoryOnlyConfigurationStore(); - configurationRequestor = new ConfigurationRequestor(httpClient, precomputedFlagStore); + precomputedFlagRequestor = new PrecomputedFlagRequestor( + httpClient, + precomputedFlagStore, + 'subject-key', + { + 'attribute-key': 'attribute-value', + }, + ); fetchSpy = jest.fn(() => { return Promise.resolve({ @@ -70,7 +77,7 @@ describe('PrecomputedRequestor', () => { describe('Precomputed flags', () => { it('Fetches and stores precomputed flag configuration', async () => { - await configurationRequestor.fetchAndStorePrecomputedFlags(); + await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); expect(fetchSpy).toHaveBeenCalledTimes(1); @@ -110,7 +117,7 @@ describe('PrecomputedRequestor', () => { }), ); - await configurationRequestor.fetchAndStorePrecomputedFlags(); + await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(precomputedFlagStore.getKeys().length).toBe(0); diff --git a/src/precomputed-requestor.ts b/src/precomputed-requestor.ts index d9ed45c..7e349be 100644 --- a/src/precomputed-requestor.ts +++ b/src/precomputed-requestor.ts @@ -2,16 +2,23 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; import { hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; import { IHttpClient } from './http-client'; import { PrecomputedFlag } from './interfaces'; +import { Attributes } from './types'; // Requests AND stores precomputed flags, reuses the configuration store export default class PrecomputedFlagRequestor { constructor( private readonly httpClient: IHttpClient, private readonly precomputedFlagStore: IConfigurationStore, + private readonly subjectKey: string, + private readonly subjectAttributes: Attributes, ) {} async fetchAndStorePrecomputedFlags(): Promise { - const precomputedResponse = await this.httpClient.getPrecomputedFlags(); + const precomputedResponse = await this.httpClient.getPrecomputedFlags({ + subject_key: this.subjectKey, + subject_attributes: this.subjectAttributes, + }); + if (!precomputedResponse?.flags) { return; }