-
Notifications
You must be signed in to change notification settings - Fork 12
/
Context.ts
438 lines (384 loc) · 13.3 KB
/
Context.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
/* eslint-disable no-underscore-dangle */
// eslint-disable-next-line max-classes-per-file
import type {
LDContext,
LDContextCommon,
LDMultiKindContext,
LDSingleKindContext,
LDUser,
} from './api';
import AttributeReference from './AttributeReference';
import { isLegacyUser, isMultiKind, isSingleKind } from './internal/context';
import { TypeValidators } from './validators';
// The general strategy for the context is to transform the passed in context
// as little as possible. We do convert the legacy users to a single kind
// context, but we do not translate all passed contexts into a rigid structure.
// The context will have to be copied for events, but we want to avoid any
// copying that we can.
// So we validate that the information we are given is correct, and then we
// just proxy calls with a nicely typed interface.
// This is to reduce work on the hot-path. Later, for event processing, deeper
// cloning of the context will be done.
// When no kind is specified, then this kind will be used.
const DEFAULT_KIND = 'user';
// The API allows for calling with an `LDContext` which is
// `LDUser | LDSingleKindContext | LDMultiKindContext`. When ingesting a context
// first the type must be determined to allow us to put it into a consistent type.
/**
* The partial URL encoding is needed because : is a valid character in context keys.
*
* Partial encoding is the replacement of all colon (:) characters with the URL
* encoded equivalent (%3A) and all percent (%) characters with the URL encoded
* equivalent (%25).
* @param key The key to encode.
* @returns Partially URL encoded key.
*/
function encodeKey(key: string): string {
if (key.includes('%') || key.includes(':')) {
return key.replace(/%/g, '%25').replace(/:/g, '%3A');
}
return key;
}
/**
* Check if the given value is a LDContextCommon.
* @param kindOrContext
* @returns true if it is an LDContextCommon
*
* Due to a limitation in the expressiveness of these highly polymorphic types any field
* in a multi-kind context can either be a context or 'kind'. So we need to re-assure
* the compiler that it isn't the word multi.
*
* Because we do not allow top level values in a multi-kind context we can validate
* that as well.
*/
function isContextCommon(
kindOrContext: 'multi' | LDContextCommon,
): kindOrContext is LDContextCommon {
return kindOrContext && TypeValidators.Object.is(kindOrContext);
}
/**
* Validate a context kind.
* @param kind
* @returns true if the kind is valid.
*/
function validKind(kind: string) {
return TypeValidators.Kind.is(kind);
}
/**
* Validate a context key.
* @param key
* @returns true if the key is valid.
*/
function validKey(key: string) {
return TypeValidators.String.is(key) && key !== '';
}
function processPrivateAttributes(
privateAttributes?: string[],
literals: boolean = false,
): AttributeReference[] {
if (privateAttributes) {
return privateAttributes.map(
(privateAttribute) => new AttributeReference(privateAttribute, literals),
);
}
return [];
}
function defined(value: any) {
return value !== null && value !== undefined;
}
/**
* Convert a legacy user to a single kind context.
* @param user
* @returns A single kind context.
*/
function legacyToSingleKind(user: LDUser): LDSingleKindContext {
const singleKindContext: LDSingleKindContext = {
// Key was coerced to a string for eval and events, so we can do that up-front.
...(user.custom || []),
kind: 'user',
key: String(user.key),
};
// For legacy users we never established a difference between null
// and undefined for inputs. Because anonymous can be used in evaluations
// we would want it to not possibly match true/false unless defined.
// Which is different than coercing a null/undefined anonymous as `false`.
if (defined(user.anonymous)) {
const anonymous = !!user.anonymous;
delete singleKindContext.anonymous;
singleKindContext.anonymous = anonymous;
}
if (user.name !== null && user.name !== undefined) {
singleKindContext.name = user.name;
}
if (user.ip !== null && user.ip !== undefined) {
singleKindContext.ip = user.ip;
}
if (user.firstName !== null && user.firstName !== undefined) {
singleKindContext.firstName = user.firstName;
}
if (user.lastName !== null && user.lastName !== undefined) {
singleKindContext.lastName = user.lastName;
}
if (user.email !== null && user.email !== undefined) {
singleKindContext.email = user.email;
}
if (user.avatar !== null && user.avatar !== undefined) {
singleKindContext.avatar = user.avatar;
}
if (user.country !== null && user.country !== undefined) {
singleKindContext.country = user.country;
}
// We are not pulling private attributes over because we will serialize
// those from attribute references for events.
return singleKindContext;
}
/**
* Container for a context/contexts. Because contexts come from external code
* they must be thoroughly validated and then formed to comply with
* the type system.
*/
export default class Context {
private context?: LDContextCommon;
private isMulti: boolean = false;
private isUser: boolean = false;
private wasLegacy: boolean = false;
private contexts: Record<string, LDContextCommon> = {};
private privateAttributeReferences?: Record<string, AttributeReference[]>;
public readonly kind: string;
/**
* Is this a valid context. If a valid context cannot be created, then this flag will be true.
* The validity of a context should be tested before it is used.
*/
public readonly valid: boolean;
public readonly message?: string;
static readonly userKind: string = DEFAULT_KIND;
/**
* Contexts should be created using the static factory method {@link Context.fromLDContext}.
* @param kind The kind of the context.
*
* The factory methods are static functions within the class because they access private
* implementation details, so they cannot be free functions.
*/
private constructor(valid: boolean, kind: string, message?: string) {
this.kind = kind;
this.valid = valid;
this.message = message;
}
private static contextForError(kind: string, message: string) {
return new Context(false, kind, message);
}
private static getValueFromContext(
reference: AttributeReference,
context?: LDContextCommon,
): any {
if (!context || !reference.isValid) {
return undefined;
}
if (reference.depth === 1 && reference.getComponent(0) === 'anonymous') {
return !!context?.anonymous;
}
return reference.get(context);
}
private contextForKind(kind: string): LDContextCommon | undefined {
if (this.isMulti) {
return this.contexts[kind];
}
if (this.kind === kind) {
return this.context;
}
return undefined;
}
private static fromMultiKindContext(context: LDMultiKindContext): Context {
const kinds = Object.keys(context).filter((key) => key !== 'kind');
const kindsValid = kinds.every(validKind);
if (!kinds.length) {
return Context.contextForError(
'multi',
'A multi-kind context must contain at least one kind',
);
}
if (!kindsValid) {
return Context.contextForError('multi', 'Context contains invalid kinds');
}
const privateAttributes: Record<string, AttributeReference[]> = {};
let contextsAreObjects = true;
const contexts = kinds.reduce((acc: Record<string, LDContextCommon>, kind) => {
const singleContext = context[kind];
if (isContextCommon(singleContext)) {
acc[kind] = singleContext;
privateAttributes[kind] = processPrivateAttributes(singleContext._meta?.privateAttributes);
} else {
// No early break isn't the most efficient, but it is an error condition.
contextsAreObjects = false;
}
return acc;
}, {});
if (!contextsAreObjects) {
return Context.contextForError('multi', 'Context contained contexts that were not objects');
}
if (!Object.values(contexts).every((part) => validKey(part.key))) {
return Context.contextForError('multi', 'Context contained invalid keys');
}
// There was only a single kind in the multi-kind context.
// So we can just translate this to a single-kind context.
if (kinds.length === 1) {
const kind = kinds[0];
const created = new Context(true, kind);
created.context = contexts[kind];
created.privateAttributeReferences = privateAttributes;
created.isUser = kind === 'user';
return created;
}
const created = new Context(true, context.kind);
created.contexts = contexts;
created.privateAttributeReferences = privateAttributes;
created.isMulti = true;
return created;
}
private static fromSingleKindContext(context: LDSingleKindContext): Context {
const { key, kind } = context;
const kindValid = validKind(kind);
const keyValid = validKey(key);
if (!kindValid) {
return Context.contextForError(kind ?? 'unknown', 'The kind was not valid for the context');
}
if (!keyValid) {
return Context.contextForError(kind, 'The key for the context was not valid');
}
// The JSON interfaces uses dangling _.
// eslint-disable-next-line no-underscore-dangle
const privateAttributeReferences = processPrivateAttributes(context._meta?.privateAttributes);
const created = new Context(true, kind);
created.isUser = kind === 'user';
created.context = context;
created.privateAttributeReferences = {
[kind]: privateAttributeReferences,
};
return created;
}
private static fromLegacyUser(context: LDUser): Context {
const keyValid = context.key !== undefined && context.key !== null;
// For legacy users we allow empty keys.
if (!keyValid) {
return Context.contextForError('user', 'The key for the context was not valid');
}
const created = new Context(true, 'user');
created.isUser = true;
created.wasLegacy = true;
created.context = legacyToSingleKind(context);
created.privateAttributeReferences = {
user: processPrivateAttributes(context.privateAttributeNames, true),
};
return created;
}
/**
* Attempt to create a {@link Context} from an {@link LDContext}.
* @param context The input context to create a Context from.
* @returns a {@link Context}, if the context was not valid, then the returned contexts `valid`
* property will be false.
*/
public static fromLDContext(context: LDContext): Context {
if (!context) {
return Context.contextForError('unknown', 'No context specified. Returning default value');
}
if (isSingleKind(context)) {
return Context.fromSingleKindContext(context);
}
if (isMultiKind(context)) {
return Context.fromMultiKindContext(context);
}
if (isLegacyUser(context)) {
return Context.fromLegacyUser(context);
}
return Context.contextForError('unknown', 'Context was not of a valid kind');
}
/**
* Attempt to get a value for the given context kind using the given reference.
* @param reference The reference to the value to get.
* @param kind The kind of the context to get the value for.
* @returns a value or `undefined` if one is not found.
*/
public valueForKind(reference: AttributeReference, kind: string = DEFAULT_KIND): any | undefined {
if (reference.isKind) {
return this.kinds;
}
return Context.getValueFromContext(reference, this.contextForKind(kind));
}
/**
* Attempt to get a key for the specified kind.
* @param kind The kind to get a key for.
* @returns The key for the specified kind, or undefined.
*/
public key(kind: string = DEFAULT_KIND): string | undefined {
return this.contextForKind(kind)?.key;
}
/**
* True if this is a multi-kind context.
*/
public get isMultiKind(): boolean {
return this.isMulti;
}
/**
* Get the canonical key for this context.
*/
public get canonicalKey(): string {
if (this.isUser) {
return this.context!.key;
}
if (this.isMulti) {
return Object.keys(this.contexts)
.sort()
.map((key) => `${key}:${encodeKey(this.contexts[key].key)}`)
.join(':');
}
return `${this.kind}:${encodeKey(this.context!.key)}`;
}
/**
* Get the kinds of this context.
*/
public get kinds(): string[] {
if (this.isMulti) {
return Object.keys(this.contexts);
}
return [this.kind];
}
/**
* Get the kinds, and their keys, for this context.
*/
public get kindsAndKeys(): Record<string, string> {
if (this.isMulti) {
return Object.entries(this.contexts).reduce(
(acc: Record<string, string>, [kind, context]) => {
acc[kind] = context.key;
return acc;
},
{},
);
}
return { [this.kind]: this.context!.key };
}
/**
* Get the attribute references.
*
* @param kind
*/
public privateAttributes(kind: string): AttributeReference[] {
return this.privateAttributeReferences?.[kind] || [];
}
/**
* Get the underlying context objects from this context.
*
* This method is intended to be used in event generation.
*
* The returned objects should not be modified.
*/
public getContexts(): [string, LDContextCommon][] {
if (this.isMulti) {
return Object.entries(this.contexts);
}
return [[this.kind, this.context!]];
}
public get legacy(): boolean {
return this.wasLegacy;
}
}