Skip to content

Commit b20061d

Browse files
authored
chore: parse safety for api (#801)
1 parent 3cdbd91 commit b20061d

File tree

4 files changed

+82
-70
lines changed

4 files changed

+82
-70
lines changed

src/feature.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ export interface EnhancedFeatureInterface extends Omit<FeatureInterface, 'strate
2525
strategies?: EnhancedStrategyTransportInterface[];
2626
}
2727

28-
export type ApiResponse = ClientFeaturesDelta | ClientFeaturesResponse;
28+
export type ApiResponse =
29+
| (ClientFeaturesDelta & { type: 'delta' })
30+
| (ClientFeaturesResponse & { type: 'full' });
2931

3032
export interface ClientFeaturesResponse {
3133
version: number;
@@ -38,16 +40,18 @@ export interface ClientFeaturesDelta {
3840
events: DeltaEvent[];
3941
}
4042

41-
export const parseClientFeaturesDelta = (delta: unknown): ClientFeaturesDelta => {
42-
if (
43-
typeof delta === 'object' &&
44-
delta !== null &&
45-
'events' in delta &&
46-
Array.isArray(delta.events)
47-
) {
48-
return delta as ClientFeaturesDelta;
43+
export const parseApiResponse = (data: unknown): ApiResponse => {
44+
if (typeof data !== 'object' || data === null) {
45+
throw new Error(`Invalid API response: ${JSON.stringify(data, null, 2)}`);
4946
}
50-
throw new Error(`Invalid delta response: ${JSON.stringify(delta, null, 2)}`);
47+
if ('events' in data && Array.isArray(data.events)) {
48+
return { ...data, type: 'delta' } as ClientFeaturesDelta & { type: 'delta' };
49+
} else if ('features' in data && Array.isArray(data.features)) {
50+
return { ...data, type: 'full' } as ClientFeaturesResponse & { type: 'full' };
51+
}
52+
throw new Error(
53+
`Client features was neither a delta nor a full response: ${JSON.stringify(data, null, 2)}`,
54+
);
5155
};
5256

5357
export type DeltaEvent =

src/repository/index.ts

Lines changed: 64 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { EventEmitter } from 'node:events';
22
import { UnleashEvents } from '../events';
3-
import type {
4-
ApiResponse,
5-
ClientFeaturesResponse,
6-
EnhancedFeatureInterface,
7-
FeatureInterface,
3+
import {
4+
type ApiResponse,
5+
type ClientFeaturesResponse,
6+
type EnhancedFeatureInterface,
7+
type FeatureInterface,
8+
parseApiResponse,
89
} from '../feature';
910
import type { CustomHeaders, CustomHeadersFunction } from '../headers';
1011
import type { HttpOptions } from '../http-options';
@@ -232,47 +233,50 @@ export default class Repository extends EventEmitter implements EventEmitter {
232233
}
233234

234235
private applyFeatureResponse(response: ApiResponse): void {
235-
if ('events' in response) {
236-
response.events.forEach((event) => {
237-
switch (event.type) {
238-
case 'feature-updated': {
239-
this.data[event.feature.name] = event.feature;
240-
break;
241-
}
242-
case 'feature-removed': {
243-
delete this.data[event.featureName];
244-
break;
245-
}
246-
case 'segment-updated': {
247-
this.segments.set(event.segment.id, event.segment);
248-
break;
249-
}
250-
case 'segment-removed': {
251-
this.segments.delete(event.segmentId);
252-
break;
253-
}
254-
case 'hydration': {
255-
this.data = this.convertToMap(event.features);
256-
this.segments = this.createSegmentLookup(event.segments);
257-
break;
236+
switch (response.type) {
237+
case 'delta': {
238+
response.events.forEach((event) => {
239+
switch (event.type) {
240+
case 'feature-updated': {
241+
this.data[event.feature.name] = event.feature;
242+
break;
243+
}
244+
case 'feature-removed': {
245+
delete this.data[event.featureName];
246+
break;
247+
}
248+
case 'segment-updated': {
249+
this.segments.set(event.segment.id, event.segment);
250+
break;
251+
}
252+
case 'segment-removed': {
253+
this.segments.delete(event.segmentId);
254+
break;
255+
}
256+
case 'hydration': {
257+
this.data = this.convertToMap(event.features);
258+
this.segments = this.createSegmentLookup(event.segments);
259+
break;
260+
}
261+
default: {
262+
this.emit(
263+
UnleashEvents.Warn,
264+
`Unknown event type received, this may or may not cause features to evaluate incorrectly: ${JSON.stringify(event)}`,
265+
);
266+
break;
267+
}
258268
}
259-
default: {
260-
this.emit(
261-
UnleashEvents.Warn,
262-
`Unknown event type received, this may or may not cause features to evaluate incorrectly: ${JSON.stringify(event)}`,
263-
);
264-
break;
265-
}
266-
}
267-
});
268-
} else if ('features' in response) {
269-
this.data = this.convertToMap(response.features);
270-
this.segments = this.createSegmentLookup(response.segments);
271-
} else {
272-
this.emit(
273-
UnleashEvents.Warn,
274-
`Unknown response when applying feature response: ${JSON.stringify(response)}`,
275-
);
269+
});
270+
break;
271+
}
272+
case 'full': {
273+
this.data = this.convertToMap(response.features);
274+
this.segments = this.createSegmentLookup(response.segments);
275+
break;
276+
}
277+
default: {
278+
assertNever(response);
279+
}
276280
}
277281
}
278282

@@ -290,7 +294,7 @@ export default class Repository extends EventEmitter implements EventEmitter {
290294
}
291295

292296
if (content && this.notEmpty(content)) {
293-
await this.save(content, false);
297+
await this.save(parseApiResponse(content), false);
294298
}
295299
} catch (err: unknown) {
296300
const message = err instanceof Error ? err.message : 'Unknown error';
@@ -302,17 +306,16 @@ Message: ${message}`,
302306
}
303307
}
304308

305-
private convertToMap(features: FeatureInterface[]): FeatureToggleData {
306-
const obj = (features || []).reduce(
307-
(o: { [s: string]: FeatureInterface }, feature: FeatureInterface) => {
308-
this.validateFeature(feature);
309-
o[feature.name] = feature;
310-
return o;
311-
},
312-
{} as { [s: string]: FeatureInterface },
313-
);
314-
315-
return obj;
309+
private convertToMap(features: FeatureInterface[] | undefined | null): FeatureToggleData {
310+
const result: FeatureToggleData = {};
311+
if (!features?.length) return {};
312+
313+
for (const feature of features) {
314+
this.validateFeature(feature);
315+
result[feature.name] = feature;
316+
}
317+
318+
return result;
316319
}
317320

318321
stop() {
@@ -373,3 +376,7 @@ Message: ${message}`,
373376
});
374377
};
375378
}
379+
380+
const assertNever = (value: never): never => {
381+
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
382+
};

src/repository/polling-fetcher.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventEmitter } from 'node:events';
22
import { UnleashEvents } from '../events';
3+
import { parseApiResponse } from '../feature';
34
import { get } from '../request';
45
import type { TagFilter } from '../tags';
56
import getUrl from '../url-utils';
@@ -165,7 +166,7 @@ export class PollingFetcher extends EventEmitter implements FetcherInterface {
165166
await this.options.onModeChange('streaming');
166167
return;
167168
}
168-
await this.options.onSave(data, true);
169+
await this.options.onSave(parseApiResponse(data), true);
169170
} catch (err) {
170171
this.emit(UnleashEvents.Error, err);
171172
}

src/repository/streaming-fetcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EventEmitter } from 'node:events';
22
import { EventSource } from '../event-source';
33
import { UnleashEvents } from '../events';
4-
import { parseClientFeaturesDelta } from '../feature';
4+
import { parseApiResponse } from '../feature';
55
import { buildHeaders } from '../request';
66
import { resolveUrl } from '../url-utils';
77
import type { FetcherInterface, StreamingFetchingOptions } from './fetcher';
@@ -127,7 +127,7 @@ export class StreamingFetcher extends EventEmitter implements FetcherInterface {
127127

128128
private async handleFlagsFromStream(event: { data: string }) {
129129
try {
130-
const data = parseClientFeaturesDelta(JSON.parse(event.data));
130+
const data = parseApiResponse(JSON.parse(event.data));
131131
await this.onSave(data, true);
132132
} catch (err) {
133133
const errorMessage =

0 commit comments

Comments
 (0)