diff --git a/.changeset/stringify-array-context-values.md b/.changeset/stringify-array-context-values.md new file mode 100644 index 00000000..975da5fd --- /dev/null +++ b/.changeset/stringify-array-context-values.md @@ -0,0 +1,6 @@ +--- +"@reflag/flag-evaluation": patch +"@reflag/node-sdk": patch +--- + +Stringify array-valued context attributes instead of flattening their elements into numeric property paths, allowing rules such as `user.roles CONTAINS "admin"` to evaluate correctly. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 2064963f..34e2e015 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -200,6 +200,7 @@ export interface Rule { /** * Flattens a nested JSON object into a single-level object, with keys indicating the nesting levels. * Keys in the resulting object are represented in a dot notation to reflect the nesting structure of the original data. + * Arrays are preserved at their property path and serialized as JSON strings. * * @param {object} data - The nested JSON object to be flattened. * @return {Record} A flattened JSON object with "stringified" keys and values. @@ -221,13 +222,7 @@ export function flattenJSON(data: object): Record { } else if (typeof value !== "object") { result[prop] = String(value); } else if (Array.isArray(value)) { - if (value.length === 0) { - result[prop] = ""; - } - - for (let i = 0; i < value.length; i++) { - recurse(value[i], prop ? prop + "." + i : "" + i); - } + result[prop] = JSON.stringify(value); } else { let isEmpty = true; diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 87fa138e..b7956af6 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -502,6 +502,39 @@ describe("evaluate flag targeting integration ", () => { }, ); + it("evaluates CONTAINS against stringified array context values", () => { + const res = evaluateFlagRules({ + flagKey: "role-based-flag", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.roles", + operator: "CONTAINS", + values: ["a"], + }, + }, + ], + context: { + user: { + roles: ["a", "b"], + }, + }, + }); + + expect(res).toEqual({ + flagKey: "role-based-flag", + value: true, + context: { + "user.roles": '["a","b"]', + }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + describe("DATE_AFTER and DATE_BEFORE in flag rules", () => { it("should evaluate DATE_AFTER operator in flag rules", () => { const res = evaluateFlagRules({ @@ -973,7 +1006,7 @@ describe("flattenJSON", () => { }); }); - it("should flatten arrays", () => { + it("should stringify arrays", () => { const input = { a: ["value1", "value2", "value3"], }; @@ -981,13 +1014,11 @@ describe("flattenJSON", () => { const output = flattenJSON(input); expect(output).toEqual({ - "a.0": "value1", - "a.1": "value2", - "a.2": "value3", + a: '["value1","value2","value3"]', }); }); - it("should handle empty arrays", () => { + it("should stringify empty arrays", () => { const input = { a: [], }; @@ -995,11 +1026,11 @@ describe("flattenJSON", () => { const output = flattenJSON(input); expect(output).toEqual({ - a: "", + a: "[]", }); }); - it("should correctly flatten mixed structures involving arrays and objects", () => { + it("should stringify arrays containing objects", () => { const input = { a: { b: ["value1", { nested: "value2" }, "value3"], @@ -1009,9 +1040,7 @@ describe("flattenJSON", () => { const output = flattenJSON(input); expect(output).toEqual({ - "a.b.0": "value1", - "a.b.1.nested": "value2", - "a.b.2": "value3", + "a.b": '["value1",{"nested":"value2"},"value3"]', }); }); @@ -1121,7 +1150,7 @@ describe("flattenJSON", () => { }); }); - it("should handle arrays with null and undefined values", () => { + it("should stringify arrays with null and undefined values", () => { const input = { a: ["value1", null, undefined, "value4"], }; @@ -1129,9 +1158,7 @@ describe("flattenJSON", () => { const output = flattenJSON(input); expect(output).toEqual({ - "a.0": "value1", - "a.1": "", - "a.3": "value4", + a: '["value1",null,null,"value4"]', }); }); @@ -1149,7 +1176,7 @@ describe("flattenJSON", () => { expect(output).toEqual({ "a.b.c": "", - "a.b.d": "", + "a.b.d": "[]", }); }); diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 0f592eba..094a1c0b 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1621,6 +1621,48 @@ describe("ReflagClient", () => { ).toBe(false); }); + it("evaluates CONTAINS against array context attributes", async () => { + const roleFlagDefinitions: FlagsAPIResponse = { + flagStateVersion: 2, + features: [ + { + key: "role-based-flag", + description: "Role-based flag", + targeting: { + version: 1, + rules: [ + { + filter: { + type: "context", + field: "user.roles", + operator: "CONTAINS", + values: ["a"], + }, + }, + ], + }, + }, + ], + }; + httpClient.get.mockResolvedValue({ + ok: true, + status: 200, + body: { success: true, ...roleFlagDefinitions }, + }); + + await client.initialize(); + + expect( + client.getFlag( + { + user: { id: "user-1", roles: ["a", "b"] }, + enableTracking: false, + }, + "role-based-flag", + ).isEnabled, + ).toBe(true); + }); + it("`track` sends all expected events when `enableTracking` is `true`", async () => { const context = { company, @@ -3035,6 +3077,17 @@ describe("getFlagsRemote", () => { ); }); + it("should stringify array context values", async () => { + await client.getFlagsRemote(undefined, undefined, { + user: { id: "u1", roles: ["a", "b"] }, + }); + + const url = new URL(httpClient.get.mock.calls[0][0]); + expect(url.searchParams.get("context.user.roles")).toBe('["a","b"]'); + expect(url.searchParams.has("context.user.roles.0")).toBe(false); + expect(url.searchParams.has("context.user.roles.1")).toBe(false); + }); + it("should not try to append the context if it's empty", async () => { await client.getFlagsRemote();