Skip to content

Commit 0f2b7a6

Browse files
feat!: Implement context support. (#16)
build(deps): Update the node-server-sdk to version 7.x. Co-authored-by: Matthew M. Keeler <[email protected]>
1 parent f50bcfc commit 0f2b7a6

File tree

4 files changed

+347
-100
lines changed

4 files changed

+347
-100
lines changed

README.md

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,91 @@ Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side
4444

4545
## OpenFeature Specific Considerations
4646

47-
When evaluating an `LDUser` with the LaunchDarkly Node SDK a string `key` attribute would normally be required. When using OpenFeature the `targetingKey` attribute should be used instead of `key`. If a `key` attribute is provided in the `EvaluationContext`, then it will be discarded in favor of `targetingKey`. If a `targetingKey` is not provided, or if the `EvaluationContext` is omitted entirely, then the `defaultValue` will be returned from OpenFeature evaluation methods.
47+
LaunchDarkly evaluates contexts, and it can either evaluate a single-context, or a multi-context. When using OpenFeature both single and multi-contexts must be encoded into a single `EvaluationContext`. This is accomplished by looking for an attribute named `kind` in the `EvaluationContext`.
4848

49-
Other fields normally included in an `LDUser` may be added to the `EvaluationContext`. Any `custom` attributes can
50-
be added to the top level of the evaluation context, and they will operate as if they were `custom` attributes on an `LDUser`. Attributes which are typically top level on an `LDUser` should be of the same types that are specified for
51-
an `LDUser` or they will not operate as intended.
49+
There are 4 different scenarios related to the `kind`:
50+
1. There is no `kind` attribute. In this case the provider will treat the context as a single context containing a "user" kind.
51+
2. There is a `kind` attribute, and the value of that attribute is "multi". This will indicate to the provider that the context is a multi-context.
52+
3. There is a `kind` attribute, and the value of that attribute is a string other than "multi". This will indicate to the provider a single context of the kind specified.
53+
4. There is a `kind` attribute, and the attribute is not a string. In this case the value of the attribute will be discarded, and the context will be treated as a "user". An error message will be logged.
5254

53-
If a top level `custom` attribute is defined on the `EvaluationContext`, then that will be a `custom` attribute inside `custom` for an `LDUser`.
55+
The `kind` attribute should be a string containing only contain ASCII letters, numbers, `.`, `_` or `-`.
5456

55-
If a custom attribute is provided, whose value is an object, then that attribute will be discarded.
57+
The OpenFeature specification allows for an optional targeting key, but LaunchDarkly requires a key for evaluation. A targeting key must be specified for each context being evaluated. It may be specified using either `targetingKey`, as it is in the OpenFeature specification, or `key`, which is the typical LaunchDarkly identifier for the targeting key. If a `targetingKey` and a `key` are specified, then the `targetingKey` will take precedence.
58+
59+
There are several other attributes which have special functionality within a single or multi-context.
60+
- A key of `privateAttributes`. Must be an array of string values. [Equivalent to '_meta.privateAttributes' in the SDK.](https://launchdarkly.github.io/node-server-sdk/interfaces/_launchdarkly_node_server_sdk_.LDContextMeta.html#privateAttributes)
61+
- A key of `anonymous`. Must be a boolean value. [Equivalent to 'anonymous' in the SDK.](https://launchdarkly.github.io/node-server-sdk/interfaces/_launchdarkly_node_server_sdk_.LDSingleKindContext.html#anonymous)
62+
- A key of `name`. Must be a string. [Equivalent to 'name' in the SDK.](https://launchdarkly.github.io/node-server-sdk/interfaces/_launchdarkly_node_server_sdk_.LDSingleKindContext.html#name)
63+
64+
### Examples
65+
66+
#### A single user context
67+
68+
```typescript
69+
const evaluationContext = {
70+
targetingKey: 'my-user-key'
71+
};
72+
```
73+
74+
#### A single context of kind "organization"
75+
76+
```typescript
77+
const evaluationContext = {
78+
kind: 'organization',
79+
targetingKey: 'my-org-key'
80+
};
81+
```
82+
83+
#### A multi-context containing a "user" and an "organization"
84+
85+
```typescript
86+
87+
const evaluationContext = {
88+
kind: 'multi',
89+
organization: {
90+
targetingKey: 'my-org-key',
91+
myCustomAttribute: 'myAttributeValue'
92+
},
93+
user: {
94+
targetingKey: 'my-user-key'
95+
}
96+
};
97+
```
98+
99+
#### Setting private attributes in a single context
100+
101+
```typescript
102+
const evaluationContext = {
103+
kind: 'organization',
104+
name: 'the-org-name',
105+
targetingKey: 'my-org-key',
106+
myCustomAttribute: 'myCustomValue',
107+
privateAttributes: ['myCustomAttribute']
108+
};
109+
```
110+
111+
#### Setting private attributes in a multi-context
112+
113+
```typescript
114+
const evaluationContext = {
115+
kind: 'multi',
116+
organization: {
117+
targetingKey: 'my-org-key',
118+
name: 'the-org-name',
119+
// This will ONLY apply to the "organization" attributes.
120+
privateAttributes: ['myCustomAttribute'],
121+
// This attribute will be private.
122+
myCustomAttribute: 'myAttributeValue'
123+
},
124+
user: {
125+
targetingKey: 'my-user-key',
126+
anonymous: true,
127+
// This attribute will not be private.
128+
myCustomAttribute: 'myAttributeValue'
129+
}
130+
};
131+
```
56132

57133
## Learn more
58134

__tests__/translateContext.test.ts

Lines changed: 148 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,24 @@ import TestLogger from './TestLogger';
33

44
it('Uses the targetingKey as the user key', () => {
55
const logger = new TestLogger();
6-
expect(translateContext(logger, { targetingKey: 'the-key' })).toEqual({ key: 'the-key' });
6+
expect(translateContext(logger, { targetingKey: 'the-key' })).toEqual({ key: 'the-key', kind: 'user' });
77
expect(logger.logs.length).toEqual(0);
88
});
99

10+
it('gives targetingKey precedence over key', () => {
11+
const logger = new TestLogger();
12+
expect(translateContext(
13+
logger,
14+
{ targetingKey: 'target-key', key: 'key-key' },
15+
)).toEqual({
16+
key: 'target-key',
17+
kind: 'user',
18+
});
19+
// Should log a warning about both being defined.
20+
expect(logger.logs.length).toEqual(1);
21+
});
22+
1023
describe.each([
11-
['secondary', 'value1'],
1224
['name', 'value2'],
1325
['firstName', 'value3'],
1426
['lastName', 'value4'],
@@ -26,20 +38,26 @@ describe.each([
2638
)).toEqual({
2739
key: 'the-key',
2840
[key]: value,
41+
kind: 'user',
2942
});
3043
expect(logger.logs.length).toEqual(0);
3144
});
3245
});
3346

47+
it.each(['key', 'targetingKey'])('handles key or targetingKey', (key) => {
48+
const logger = new TestLogger();
49+
expect(translateContext(
50+
logger,
51+
{ [key]: 'the-key' },
52+
)).toEqual({
53+
key: 'the-key',
54+
kind: 'user',
55+
});
56+
expect(logger.logs.length).toEqual(0);
57+
});
58+
3459
describe.each([
35-
['secondary', false],
3660
['name', 17],
37-
['firstName', new Date()],
38-
['lastName', true],
39-
['email', () => { }],
40-
['avatar', {}],
41-
['ip', []],
42-
['country', 42],
4361
['anonymous', 'value'],
4462
])('given incorrect built-in attributes', (key, value) => {
4563
it('the bad key is omitted', () => {
@@ -49,6 +67,7 @@ describe.each([
4967
{ targetingKey: 'the-key', [key]: value },
5068
)).toEqual({
5169
key: 'the-key',
70+
kind: 'user',
5271
});
5372
expect(logger.logs[0]).toMatch(new RegExp(`The attribute '${key}' must be of type.*`));
5473
});
@@ -58,21 +77,12 @@ it('accepts custom attributes', () => {
5877
const logger = new TestLogger();
5978
expect(translateContext(logger, { targetingKey: 'the-key', someAttr: 'someValue' })).toEqual({
6079
key: 'the-key',
61-
custom: {
62-
someAttr: 'someValue',
63-
},
80+
kind: 'user',
81+
someAttr: 'someValue',
6482
});
6583
expect(logger.logs.length).toEqual(0);
6684
});
6785

68-
it('ignores custom attributes that are objects', () => {
69-
const logger = new TestLogger();
70-
expect(translateContext(logger, { targetingKey: 'the-key', someAttr: {} })).toEqual({
71-
key: 'the-key',
72-
});
73-
expect(logger.logs[0]).toEqual("The attribute 'someAttr' is of an unsupported type 'object'");
74-
});
75-
7686
it('accepts string/boolean/number arrays', () => {
7787
const logger = new TestLogger();
7888
expect(translateContext(logger, {
@@ -82,40 +92,137 @@ it('accepts string/boolean/number arrays', () => {
8292
booleans: [true, false],
8393
})).toEqual({
8494
key: 'the-key',
85-
custom: {
86-
strings: ['a', 'b', 'c'],
87-
numbers: [1, 2, 3],
88-
booleans: [true, false],
89-
},
95+
kind: 'user',
96+
strings: ['a', 'b', 'c'],
97+
numbers: [1, 2, 3],
98+
booleans: [true, false],
9099
});
91100
expect(logger.logs.length).toEqual(0);
92101
});
93102

94-
it('discards invalid array types', () => {
103+
it('converts date to ISO strings', () => {
104+
const date = new Date();
95105
const logger = new TestLogger();
96106
expect(translateContext(
97107
logger,
98-
{
99-
targetingKey: 'the-key',
100-
dates: [new Date()],
101-
},
108+
{ targetingKey: 'the-key', date },
102109
)).toEqual({
103110
key: 'the-key',
111+
kind: 'user',
112+
date: date.toISOString(),
104113
});
105-
expect(logger.logs[0]).toEqual("The attribute 'dates' is an unsupported array type.");
114+
expect(logger.logs.length).toEqual(0);
106115
});
107116

108-
it('converts date to ISO strings', () => {
109-
const date = new Date();
117+
it('can convert a single kind context', () => {
118+
const evaluationContext = {
119+
kind: 'organization',
120+
targetingKey: 'my-org-key',
121+
};
122+
123+
const expectedContext = {
124+
kind: 'organization',
125+
key: 'my-org-key',
126+
};
127+
110128
const logger = new TestLogger();
111-
expect(translateContext(
112-
logger,
113-
{ targetingKey: 'the-key', date },
114-
)).toEqual({
115-
key: 'the-key',
116-
custom: {
117-
date: date.toISOString(),
129+
expect(translateContext(logger, evaluationContext)).toEqual(expectedContext);
130+
expect(logger.logs.length).toEqual(0);
131+
});
132+
133+
it('can convert a multi-context', () => {
134+
const evaluationContext = {
135+
kind: 'multi',
136+
organization: {
137+
targetingKey: 'my-org-key',
138+
myCustomAttribute: 'myAttributeValue',
118139
},
119-
});
140+
user: {
141+
targetingKey: 'my-user-key',
142+
},
143+
};
144+
145+
const expectedContext = {
146+
kind: 'multi',
147+
organization: {
148+
key: 'my-org-key',
149+
myCustomAttribute: 'myAttributeValue',
150+
},
151+
user: {
152+
key: 'my-user-key',
153+
},
154+
};
155+
156+
const logger = new TestLogger();
157+
expect(translateContext(logger, evaluationContext)).toEqual(expectedContext);
158+
expect(logger.logs.length).toEqual(0);
159+
});
160+
161+
it('can handle privateAttributes in a single context', () => {
162+
const evaluationContext = {
163+
kind: 'organization',
164+
name: 'the-org-name',
165+
targetingKey: 'my-org-key',
166+
myCustomAttribute: 'myCustomValue',
167+
privateAttributes: ['myCustomAttribute'],
168+
};
169+
170+
const expectedContext = {
171+
kind: 'organization',
172+
name: 'the-org-name',
173+
key: 'my-org-key',
174+
myCustomAttribute: 'myCustomValue',
175+
_meta: {
176+
privateAttributes: ['myCustomAttribute'],
177+
},
178+
};
179+
180+
const logger = new TestLogger();
181+
expect(translateContext(logger, evaluationContext)).toEqual(expectedContext);
182+
expect(logger.logs.length).toEqual(0);
183+
});
184+
185+
it('detects a cycle and logs an error', () => {
186+
const a = {
187+
b: { c: {} },
188+
};
189+
190+
a.b.c = a;
191+
const evaluationContext = {
192+
key: 'a-key',
193+
kind: 'singularity',
194+
a,
195+
};
196+
197+
const expectedContext = {
198+
key: 'a-key',
199+
kind: 'singularity',
200+
a: { b: {} },
201+
};
202+
203+
const logger = new TestLogger();
204+
expect(translateContext(logger, evaluationContext)).toEqual(expectedContext);
205+
expect(logger.logs.length).toEqual(1);
206+
});
207+
208+
it('allows references in different branches', () => {
209+
const a = { test: 'test' };
210+
211+
const evaluationContext = {
212+
key: 'a-key',
213+
kind: 'singularity',
214+
b: { a },
215+
c: { a },
216+
};
217+
218+
const expectedContext = {
219+
key: 'a-key',
220+
kind: 'singularity',
221+
b: { a: { test: 'test' } },
222+
c: { a: { test: 'test' } },
223+
};
224+
225+
const logger = new TestLogger();
226+
expect(translateContext(logger, evaluationContext)).toEqual(expectedContext);
120227
expect(logger.logs.length).toEqual(0);
121228
});

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"license": "Apache-2.0",
2222
"peerDependencies": {
2323
"@openfeature/js-sdk": "^1.0.0",
24-
"launchdarkly-node-server-sdk": "6.x"
24+
"launchdarkly-node-server-sdk": "7.x"
2525
},
2626
"devDependencies": {
2727
"@openfeature/js-sdk": "^1.0.0",
@@ -34,7 +34,7 @@
3434
"eslint-plugin-import": "^2.26.0",
3535
"jest": "^27.5.1",
3636
"jest-junit": "^14.0.1",
37-
"launchdarkly-node-server-sdk": "6.x",
37+
"launchdarkly-node-server-sdk": "7.x",
3838
"ts-jest": "^27.1.4",
3939
"typescript": "^4.7.4"
4040
}

0 commit comments

Comments
 (0)