Skip to content

Commit a83d3a5

Browse files
authored
fix: handle environment documentation pagination (#205)
* fix: iterate-over-environment-documents-links-and-compile * fix: changelog * feat: removed-features-states-handling * feat: removed-only * fix: implemented-parsing-next-link-from-header * feat: reworked-mocked-data-to-surface-next-header * feat: removed-local-catch-error
1 parent ef2b97a commit a83d3a5

File tree

3 files changed

+204
-7
lines changed

3 files changed

+204
-7
lines changed

sdk/index.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ export class Flagsmith {
329329
url: string,
330330
method: string,
331331
body?: { [key: string]: any }
332-
): Promise<any> {
332+
): Promise<{ response: Response; data: any }> {
333333
const headers: { [key: string]: any } = { 'Content-Type': 'application/json' };
334334
if (this.environmentKey) {
335335
headers['X-Environment-Key'] = this.environmentKey as string;
@@ -363,7 +363,7 @@ export class Flagsmith {
363363
);
364364
}
365365

366-
return data.json();
366+
return { response: data, data: await data.json() };
367367
}
368368

369369
/**
@@ -393,8 +393,52 @@ export class Flagsmith {
393393
if (!this.environmentUrl) {
394394
throw new Error('`apiUrl` argument is missing or invalid.');
395395
}
396-
const environment_data = await this.getJSONResponse(this.environmentUrl, 'GET');
397-
return buildEnvironmentModel(environment_data);
396+
const startTime = Date.now();
397+
const documents: any[] = [];
398+
let url = this.environmentUrl;
399+
let loggedWarning = false;
400+
401+
while (true) {
402+
try {
403+
if (!loggedWarning) {
404+
const elapsedMs = Date.now() - startTime;
405+
if (elapsedMs > this.environmentRefreshIntervalSeconds * 1000) {
406+
this.logger.warn(
407+
`Environment document retrieval exceeded the polling interval of ${this.environmentRefreshIntervalSeconds} seconds.`
408+
);
409+
loggedWarning = true;
410+
}
411+
}
412+
413+
const { response, data } = await this.getJSONResponse(url, 'GET');
414+
415+
documents.push(data);
416+
417+
const linkHeader = response.headers.get('link');
418+
if (linkHeader) {
419+
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
420+
421+
if (nextMatch) {
422+
const relativeUrl = decodeURIComponent(nextMatch[1]);
423+
url = new URL(relativeUrl, this.apiUrl).href;
424+
425+
continue;
426+
}
427+
}
428+
break;
429+
} catch (error) {
430+
throw error;
431+
}
432+
}
433+
434+
// Compile the document
435+
const compiledDocument = documents[0];
436+
for (let i = 1; i < documents.length; i++) {
437+
compiledDocument.identity_overrides = compiledDocument.identity_overrides || [];
438+
compiledDocument.identity_overrides.push(...(documents[i].identity_overrides || []));
439+
}
440+
441+
return buildEnvironmentModel(compiledDocument);
398442
}
399443

400444
private async getEnvironmentFlagsFromDocument(): Promise<Flags> {
@@ -444,7 +488,7 @@ export class Flagsmith {
444488
if (!this.environmentFlagsUrl) {
445489
throw new Error('`apiUrl` argument is missing or invalid.');
446490
}
447-
const apiFlags = await this.getJSONResponse(this.environmentFlagsUrl, 'GET');
491+
const { data: apiFlags } = await this.getJSONResponse(this.environmentFlagsUrl, 'GET');
448492
const flags = Flags.fromAPIFlags({
449493
apiFlags: apiFlags,
450494
analyticsProcessor: this.analyticsProcessor,
@@ -465,7 +509,7 @@ export class Flagsmith {
465509
throw new Error('`apiUrl` argument is missing or invalid.');
466510
}
467511
const data = generateIdentitiesData(identifier, traits, transient);
468-
const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data);
512+
const { data: jsonResponse } = await this.getJSONResponse(this.identitiesUrl, 'POST', data);
469513
const flags = Flags.fromAPIFlags({
470514
apiFlags: jsonResponse['flags'],
471515
analyticsProcessor: this.analyticsProcessor,

tests/engine/unit/engine.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
environmentWithSegmentOverride,
1313
feature1,
1414
getEnvironmentFeatureStateForFeature,
15-
getEnvironmentFeatureStateForFeatureByName,
1615
identity,
1716
identityInSegment,
1817
segmentConditionProperty,

tests/sdk/flagsmith.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,160 @@ test('test_update_environment_sets_environment', async () => {
4040
expect(await flg.getEnvironment()).toStrictEqual(model);
4141
});
4242

43+
test('test_update_environment_handles_paginated_document', async () => {
44+
type EnvDocumentMockResponse = {
45+
responseHeader: string | null;
46+
page: any;
47+
};
48+
49+
const createMockFetch = (pages: EnvDocumentMockResponse[]) => {
50+
let callCount = 0;
51+
return vi.fn((url: string, options?: RequestInit) => {
52+
if (url.includes('/environment-document')) {
53+
const document = envDocumentMockResponse[callCount];
54+
if (document) {
55+
callCount++;
56+
57+
const responseHeaders: Record<string, string> = {};
58+
59+
if (document.responseHeader) {
60+
responseHeaders['Link'] = `<${document.responseHeader}>; rel="next"`;
61+
}
62+
63+
return Promise.resolve(
64+
new Response(JSON.stringify(document.page), {
65+
status: 200,
66+
headers: responseHeaders
67+
})
68+
);
69+
}
70+
}
71+
return Promise.resolve(new Response('unknown url ' + url, { status: 404 }));
72+
});
73+
};
74+
75+
const envDocumentMockResponse: EnvDocumentMockResponse[] = [
76+
{
77+
responseHeader: '/api/v1/environment-document?page=2',
78+
page: {
79+
id: 1,
80+
api_key: 'test-key',
81+
project: {
82+
id: 1,
83+
name: 'test',
84+
organisation: {
85+
id: 1,
86+
name: 'Test Org',
87+
feature_analytics: false,
88+
persist_trait_data: true,
89+
stop_serving_flags: false
90+
},
91+
hide_disabled_flags: false,
92+
segments: []
93+
},
94+
feature_states: [
95+
{
96+
feature_state_value: 'first_page_feature_state',
97+
multivariate_feature_state_values: [],
98+
django_id: 81027,
99+
feature: {
100+
id: 15058,
101+
type: 'STANDARD',
102+
name: 'string_feature'
103+
},
104+
enabled: false
105+
},
106+
{
107+
feature_state_value: 'second_page_feature_state',
108+
multivariate_feature_state_values: [],
109+
django_id: 81027,
110+
feature: {
111+
id: 15058,
112+
type: 'STANDARD',
113+
name: 'string_feature'
114+
},
115+
enabled: false
116+
},
117+
{
118+
feature_state_value: 'third_page_feature_state',
119+
multivariate_feature_state_values: [],
120+
django_id: 81027,
121+
feature: {
122+
id: 15058,
123+
type: 'STANDARD',
124+
name: 'string_feature'
125+
},
126+
enabled: false
127+
}
128+
],
129+
identity_overrides: [{ id: 1, identifier: 'user1' }]
130+
}
131+
},
132+
{
133+
responseHeader: '/api/v1/environment-document?page=3',
134+
page: {
135+
api_key: 'test-key',
136+
project: {
137+
id: 1,
138+
name: 'test',
139+
organisation: {
140+
id: 1,
141+
name: 'Test Org',
142+
feature_analytics: false,
143+
persist_trait_data: true,
144+
stop_serving_flags: false
145+
},
146+
hide_disabled_flags: false,
147+
segments: []
148+
},
149+
feature_states: [],
150+
identity_overrides: [{ id: 2, identifier: 'user2' }]
151+
}
152+
},
153+
{
154+
responseHeader: null,
155+
page: {
156+
api_key: 'test-key',
157+
project: {
158+
id: 1,
159+
name: 'test',
160+
organisation: {
161+
id: 1,
162+
name: 'Test Org',
163+
feature_analytics: false,
164+
persist_trait_data: true,
165+
stop_serving_flags: false
166+
},
167+
hide_disabled_flags: false,
168+
segments: []
169+
},
170+
feature_states: [],
171+
identity_overrides: [{ id: 2, identifier: 'user3' }]
172+
}
173+
}
174+
];
175+
176+
const flg = new Flagsmith({
177+
environmentKey: 'ser.key',
178+
enableLocalEvaluation: true,
179+
fetch: createMockFetch(envDocumentMockResponse)
180+
});
181+
182+
const environment = await flg.getEnvironment();
183+
184+
expect(environment.identityOverrides).toHaveLength(3);
185+
expect(environment.identityOverrides[0].identifier).toBe('user1');
186+
expect(environment.identityOverrides[1].identifier).toBe('user2');
187+
expect(environment.identityOverrides[2].identifier).toBe('user3');
188+
expect(environment.featureStates).toHaveLength(3);
189+
expect(environment.featureStates[0].getValue()).toBe('first_page_feature_state');
190+
expect(environment.featureStates[1].getValue()).toBe('second_page_feature_state');
191+
expect(environment.featureStates[2].getValue()).toBe('third_page_feature_state');
192+
expect(environment.project.name).toBe('test');
193+
expect(environment.project.organisation.name).toBe('Test Org');
194+
expect(environment.project.organisation.id).toBe(1);
195+
});
196+
43197
test('test_set_agent_options', async () => {
44198
const agent = new Agent({});
45199

0 commit comments

Comments
 (0)