1 package ldmodel
2
3 import (
4 "encoding/json"
5
6 "github.com/launchdarkly/go-sdk-common/v3/ldattr"
7 "github.com/launchdarkly/go-sdk-common/v3/ldcontext"
8 "github.com/launchdarkly/go-sdk-common/v3/ldtime"
9 "github.com/launchdarkly/go-sdk-common/v3/ldvalue"
10 )
11
12 type flagSerializationTestParams struct {
13 name string
14 flag FeatureFlag
15 jsonString string
16 jsonAltInputs []string
17
18 isCustomClientSideAvailability bool
19 }
20
21 type segmentSerializationTestParams struct {
22 name string
23 segment Segment
24 jsonString string
25 jsonAltInputs []string
26 }
27
28 type clauseSerializationTestParams struct {
29 name string
30 clause Clause
31 jsonString string
32 jsonAltInputs []string
33 }
34
35 type rolloutSerializationTestParams struct {
36 name string
37 rollout Rollout
38 jsonString string
39 jsonAltInputs []string
40 }
41
42 var flagTopLevelDefaultProperties = map[string]interface{}{
43 "key": "",
44 "deleted": false,
45 "variations": []interface{}{},
46 "on": false,
47 "offVariation": nil,
48 "fallthrough": map[string]interface{}{},
49 "targets": []interface{}{},
50 "contextTargets": []interface{}{},
51 "prerequisites": []interface{}{},
52 "rules": []interface{}{},
53 "clientSide": false,
54 "trackEvents": false,
55 "trackEventsFallthrough": false,
56 "debugEventsUntilDate": nil,
57 "salt": "",
58 "version": 0,
59 }
60
61 var segmentTopLevelDefaultProperties = map[string]interface{}{
62 "key": "",
63 "deleted": false,
64 "version": 0,
65 "generation": nil,
66 "included": []string{},
67 "excluded": []string{},
68 "includedContexts": []interface{}{},
69 "excludedContexts": []interface{}{},
70 "rules": []interface{}{},
71 "salt": "",
72 }
73
74 func makeFlagSerializationTestParams() []flagSerializationTestParams {
75 ret := []flagSerializationTestParams{
76 {
77 name: "defaults",
78 flag: FeatureFlag{},
79 jsonString: `{}`,
80 jsonAltInputs: []string{
81 `{"deleted": false}`,
82 `{"on": false}`,
83 `{"offVariation": null}`,
84 `{"fallthrough": {"variation": null}}`,
85 `{"prerequisites": []}`,
86 `{"targets": []}`,
87 `{"rules": []}`,
88 },
89 },
90 {
91 name: "key",
92 flag: FeatureFlag{Key: "flag-key"},
93 jsonString: `{"key": "flag-key"}`,
94 },
95 {
96 name: "version",
97 flag: FeatureFlag{Version: 99},
98 jsonString: `{"version": 99}`,
99 },
100 {
101 name: "deleted",
102 flag: FeatureFlag{Deleted: true},
103 jsonString: `{"deleted": true}`,
104 },
105 {
106 name: "variations",
107 flag: FeatureFlag{Variations: []ldvalue.Value{
108 ldvalue.Bool(true),
109 ldvalue.Int(1),
110 ldvalue.Float64(1.5),
111 ldvalue.String("x"),
112 ldvalue.ArrayOf(),
113 ldvalue.ObjectBuild().Build(),
114 },
115 },
116 jsonString: `{"variations": [true, 1, 1.5, "x", [], {}]}`,
117 },
118 {
119 name: "on",
120 flag: FeatureFlag{On: true},
121 jsonString: `{"on": true}`,
122 },
123 {
124 name: "offVariation",
125 flag: FeatureFlag{OffVariation: ldvalue.NewOptionalInt(1)},
126 jsonString: `{"offVariation": 1}`,
127 },
128 {
129 name: "fallthrough variation",
130 flag: FeatureFlag{Fallthrough: VariationOrRollout{Variation: ldvalue.NewOptionalInt(1)}},
131 jsonString: `{"fallthrough": {"variation": 1}}`,
132 jsonAltInputs: []string{`{"fallthrough": {"variation": 1, "rollout": null}}`},
133 },
134 {
135 name: "prerequisites",
136 flag: FeatureFlag{
137 Prerequisites: []Prerequisite{
138 {Variation: 1, Key: "pre-key"},
139 },
140 },
141 jsonString: `{"prerequisites": [ {"variation": 1, "key": "pre-key"} ]}`,
142 },
143 {
144 name: "targets",
145 flag: FeatureFlag{
146 Targets: []Target{
147 {Variation: 1, Values: []string{"a", "b"}},
148 },
149 },
150 jsonString: `{"targets": [ {"variation": 1, "values": ["a", "b"]} ]}`,
151 },
152 {
153 name: "contextTargets",
154 flag: FeatureFlag{
155 ContextTargets: []Target{
156 {ContextKind: "org", Variation: 1, Values: []string{"a", "b"}},
157 },
158 },
159 jsonString: `{"contextTargets": [ {"contextKind": "org", "variation": 1, "values": ["a", "b"]} ]}`,
160 },
161 {
162 name: "minimal rule with variation",
163 flag: FeatureFlag{
164 Rules: []FlagRule{
165 {VariationOrRollout: VariationOrRollout{Variation: ldvalue.NewOptionalInt(1)}},
166 },
167 },
168 jsonString: `{"rules": [ {"variation": 1, "clauses": [], "trackEvents": false} ]}`,
169 jsonAltInputs: []string{`{"rules": [ {"variation": 1} ]}`},
170 },
171 {
172 name: "rule ID",
173 flag: FeatureFlag{
174 Rules: []FlagRule{
175 {ID: "a", VariationOrRollout: VariationOrRollout{Variation: ldvalue.NewOptionalInt(1)}},
176 },
177 },
178 jsonString: `{"rules": [ {"id": "a", "variation": 1, "clauses": [], "trackEvents": false} ]}`,
179 },
180 {
181 name: "rule trackEvents",
182 flag: FeatureFlag{
183 Rules: []FlagRule{
184 {VariationOrRollout: VariationOrRollout{Variation: ldvalue.NewOptionalInt(1)}, TrackEvents: true},
185 },
186 },
187 jsonString: `{"rules": [ {"variation": 1, "clauses": [], "trackEvents": true} ]}`,
188 },
189 {
190 name: "clientSide",
191 flag: FeatureFlag{
192 ClientSideAvailability: ClientSideAvailability{
193 UsingMobileKey: true,
194 UsingEnvironmentID: true,
195 Explicit: false,
196 },
197 },
198 jsonString: `{"clientSide": true}`,
199 isCustomClientSideAvailability: true,
200 },
201 {
202 name: "clientSide explicitly false",
203 flag: FeatureFlag{
204 ClientSideAvailability: ClientSideAvailability{
205 UsingMobileKey: true,
206 UsingEnvironmentID: false,
207 Explicit: false,
208 },
209 },
210 jsonString: `{"clientSide": false}`,
211 isCustomClientSideAvailability: true,
212 },
213 {
214 name: "clientSideAvailability both false",
215 flag: FeatureFlag{
216 ClientSideAvailability: ClientSideAvailability{
217 Explicit: true,
218 },
219 },
220 jsonString: `{"clientSideAvailability": {"usingMobileKey": false, "usingEnvironmentId": false}}`,
221 isCustomClientSideAvailability: true,
222 },
223 {
224 name: "clientSideAvailability both true",
225 flag: FeatureFlag{
226 ClientSideAvailability: ClientSideAvailability{
227 UsingMobileKey: true,
228 UsingEnvironmentID: true,
229 Explicit: true,
230 },
231 },
232 jsonString: `{"clientSide": true, "clientSideAvailability": {"usingMobileKey": true, "usingEnvironmentId": true}}`,
233 isCustomClientSideAvailability: true,
234 },
235 {
236 name: "clientSideAvailability usingMobileKey only",
237 flag: FeatureFlag{
238 ClientSideAvailability: ClientSideAvailability{
239 UsingMobileKey: true,
240 Explicit: true,
241 },
242 },
243 jsonString: `{"clientSideAvailability": {"usingMobileKey": true, "usingEnvironmentId": false}}`,
244 isCustomClientSideAvailability: true,
245 },
246 {
247 name: "clientSideAvailability usingEnvironmentId only",
248 flag: FeatureFlag{
249 ClientSideAvailability: ClientSideAvailability{
250 UsingEnvironmentID: true,
251 Explicit: true,
252 },
253 },
254 jsonString: `{"clientSide": true, "clientSideAvailability": {"usingMobileKey": false, "usingEnvironmentId": true}}`,
255 isCustomClientSideAvailability: true,
256 },
257 {
258 name: "salt",
259 flag: FeatureFlag{Salt: "flag-salt"},
260 jsonString: `{"salt": "flag-salt"}`,
261 },
262 {
263 name: "trackEvents",
264 flag: FeatureFlag{TrackEvents: true},
265 jsonString: `{"trackEvents": true}`,
266 },
267 {
268 name: "trackEventsFallthrough",
269 flag: FeatureFlag{TrackEventsFallthrough: true},
270 jsonString: `{"trackEventsFallthrough": true}`,
271 },
272 {
273 name: "debugEventsUntilDate",
274 flag: FeatureFlag{DebugEventsUntilDate: ldtime.UnixMillisecondTime(1000)},
275 jsonString: `{"debugEventsUntilDate": 1000}`,
276 },
277 }
278
279 makeFlagJSONForClause := func(clauseJSON string) string {
280 return `{"rules": [ {"variation": 1, "clauses": [` + clauseJSON + `], "trackEvents": false }]}`
281 }
282 for _, cp := range makeClauseSerializationTestParams() {
283 fp := flagSerializationTestParams{
284 name: "rule clause " + cp.name,
285 flag: FeatureFlag{
286 Rules: []FlagRule{
287 {
288 VariationOrRollout: VariationOrRollout{Variation: ldvalue.NewOptionalInt(1)},
289 Clauses: []Clause{cp.clause},
290 },
291 },
292 },
293 jsonString: makeFlagJSONForClause(cp.jsonString),
294 }
295 for _, alt := range cp.jsonAltInputs {
296 fp.jsonAltInputs = append(fp.jsonAltInputs, makeFlagJSONForClause(alt))
297 }
298 ret = append(ret, fp)
299 }
300
301 makeFlagJSONForFallthroughRollout := func(rolloutJSON string) string {
302 return `{"fallthrough": {"rollout": ` + rolloutJSON + `}}`
303 }
304 makeFlagJSONForRuleRollout := func(rolloutJSON string) string {
305 return `{"rules": [ {"rollout": ` + rolloutJSON + `, "clauses": [], "trackEvents": false} ]}`
306 }
307 for _, rp := range makeRolloutSerializationTestParams() {
308 fp1 := flagSerializationTestParams{
309 name: "fallthrough rollout " + rp.name,
310 flag: FeatureFlag{
311 Fallthrough: VariationOrRollout{Rollout: rp.rollout},
312 },
313 jsonString: makeFlagJSONForFallthroughRollout(rp.jsonString),
314 }
315 for _, alt := range rp.jsonAltInputs {
316 fp1.jsonAltInputs = append(fp1.jsonAltInputs, makeFlagJSONForFallthroughRollout(alt))
317 }
318 fp2 := flagSerializationTestParams{
319 name: "rule rollout " + rp.name,
320 flag: FeatureFlag{
321 Rules: []FlagRule{{VariationOrRollout: VariationOrRollout{Rollout: rp.rollout}}},
322 },
323 jsonString: makeFlagJSONForRuleRollout(rp.jsonString),
324 }
325 for _, alt := range rp.jsonAltInputs {
326 fp2.jsonAltInputs = append(fp2.jsonAltInputs, makeFlagJSONForRuleRollout(alt))
327 }
328 ret = append(ret, fp1, fp2)
329 }
330
331 return ret
332 }
333
334 func makeSegmentSerializationTestParams() []segmentSerializationTestParams {
335 ret := []segmentSerializationTestParams{
336 {
337 name: "defaults",
338 segment: Segment{},
339 jsonString: `{}`,
340 jsonAltInputs: []string{
341 `{"deleted": false}`,
342 `{"included": []}`,
343 `{"excluded": []}`,
344 `{"rules": []}`,
345 `{"unbounded": false}`,
346 `{"generation": null}`,
347 },
348 },
349 {
350 name: "key",
351 segment: Segment{Key: "segment-key"},
352 jsonString: `{"key": "segment-key"}`,
353 },
354 {
355 name: "version",
356 segment: Segment{Version: 99},
357 jsonString: `{"version": 99}`,
358 },
359 {
360 name: "deleted",
361 segment: Segment{Deleted: true},
362 jsonString: `{"deleted": true}`,
363 },
364 {
365 name: "included",
366 segment: Segment{Included: []string{"a", "b"}},
367 jsonString: `{"included": ["a", "b"]}`,
368 },
369 {
370 name: "excluded",
371 segment: Segment{Excluded: []string{"a", "b"}},
372 jsonString: `{"excluded": ["a", "b"]}`,
373 },
374 {
375 name: "includedContexts",
376 segment: Segment{
377 IncludedContexts: []SegmentTarget{
378 {ContextKind: "org", Values: []string{"a", "b"}},
379 }},
380 jsonString: `{"includedContexts": [ {"contextKind": "org", "values": ["a", "b"]} ]}`,
381 },
382 {
383 name: "excludedContexts",
384 segment: Segment{
385 ExcludedContexts: []SegmentTarget{
386 {ContextKind: "org", Values: []string{"a", "b"}},
387 }},
388 jsonString: `{"excludedContexts": [ {"contextKind": "org", "values": ["a", "b"]} ]}`,
389 },
390 {
391 name: "minimal rule",
392 segment: Segment{
393 Rules: []SegmentRule{
394 {},
395 },
396 },
397 jsonString: `{"rules": [ {"id": "", "clauses": []} ]}`,
398 jsonAltInputs: []string{`{"rules": [ {} ]}`},
399 },
400 {
401 name: "minimal rule with weight",
402 segment: Segment{
403 Rules: []SegmentRule{
404 {Weight: ldvalue.NewOptionalInt(100000)},
405 },
406 },
407 jsonString: `{"rules": [ {"id": "", "weight": 100000, "clauses": []} ]}`,
408 },
409 {
410 name: "rule bucketBy",
411 segment: Segment{
412 Rules: []SegmentRule{
413 {Weight: ldvalue.NewOptionalInt(100000), BucketBy: ldattr.NewLiteralRef("name")},
414 },
415 },
416 jsonString: `{"rules": [ {"id": "", "weight": 100000, "bucketBy": "name", "clauses": []} ]}`,
417 },
418 {
419 name: "rule bucketBy invalid ref",
420
421 segment: Segment{
422 Rules: []SegmentRule{
423 {
424 RolloutContextKind: ldcontext.Kind("user"),
425 Weight: ldvalue.NewOptionalInt(100000),
426 BucketBy: ldattr.NewRef("///"),
427 },
428 },
429 },
430 jsonString: `{"rules": [ {"id": "", "weight": 100000, "rolloutContextKind": "user", "bucketBy": "///", "clauses": []} ]}`,
431 },
432 {
433 name: "rule rolloutContextKind",
434 segment: Segment{
435 Rules: []SegmentRule{
436 {Weight: ldvalue.NewOptionalInt(100000), RolloutContextKind: ldcontext.Kind("org")},
437 },
438 },
439 jsonString: `{"rules": [ {"id": "", "weight": 100000, "rolloutContextKind": "org", "clauses": []} ]}`,
440 },
441 {
442 name: "rule ID",
443 segment: Segment{
444 Rules: []SegmentRule{
445 {ID: "a"},
446 },
447 },
448 jsonString: `{"rules": [ {"id": "a", "clauses": []} ]}`,
449 },
450 {
451 name: "unbounded and generation",
452 segment: Segment{Unbounded: true, Generation: ldvalue.NewOptionalInt(1)},
453 jsonString: `{"unbounded": true, "generation": 1}`,
454 },
455 {
456 name: "unbounded and generation and unboundedContextKind",
457 segment: Segment{Unbounded: true, UnboundedContextKind: "org", Generation: ldvalue.NewOptionalInt(1)},
458 jsonString: `{"unbounded": true, "unboundedContextKind": "org", "generation": 1}`,
459 },
460 {
461 name: "salt",
462 segment: Segment{Salt: "segment-salt"},
463 jsonString: `{"salt": "segment-salt"}`,
464 },
465 }
466
467 makeSegmentJSONForClause := func(clauseJSON string) string {
468 return `{"rules": [ {"id": "", "clauses": [` + clauseJSON + `]} ]}`
469 }
470 for _, cp := range makeClauseSerializationTestParams() {
471 sp := segmentSerializationTestParams{
472 name: "segment rule clause " + cp.name,
473 segment: Segment{
474 Rules: []SegmentRule{
475 {
476 Clauses: []Clause{cp.clause},
477 },
478 },
479 },
480 jsonString: makeSegmentJSONForClause(cp.jsonString),
481 }
482 for _, alt := range cp.jsonAltInputs {
483 sp.jsonAltInputs = append(sp.jsonAltInputs, makeSegmentJSONForClause(alt))
484 }
485 ret = append(ret, sp)
486 }
487
488 return ret
489 }
490
491 func makeClauseSerializationTestParams() []clauseSerializationTestParams {
492 return []clauseSerializationTestParams{
493 {
494 name: "simple",
495 clause: Clause{
496 Attribute: ldattr.NewLiteralRef("key"),
497 Op: OperatorIn,
498 Values: []ldvalue.Value{ldvalue.String("a")},
499 },
500 jsonString: `{"attribute": "key", "op": "in", "values": ["a"], "negate": false}`,
501 jsonAltInputs: []string{`{"attribute": "key", "op": "in", "values": ["a"]}`},
502 },
503 {
504 name: "with kind",
505 clause: Clause{
506 ContextKind: ldcontext.Kind("org"),
507 Attribute: ldattr.NewLiteralRef("key"),
508 Op: OperatorIn,
509 Values: []ldvalue.Value{ldvalue.String("a")},
510 },
511 jsonString: `{"contextKind": "org", "attribute": "key", "op": "in", "values": ["a"], "negate": false}`,
512 },
513 {
514 name: "with kind and complex attribute ref",
515 clause: Clause{
516 ContextKind: ldcontext.Kind("user"),
517 Attribute: ldattr.NewRef("/attr1/subprop"),
518 Op: OperatorIn,
519 Values: []ldvalue.Value{ldvalue.String("a")},
520 },
521 jsonString: `{"contextKind": "user", "attribute": "/attr1/subprop", "op": "in", "values": ["a"], "negate": false}`,
522 },
523 {
524 name: "attribute is treated as plain name and not path when kind is omitted",
525 clause: Clause{
526 Attribute: ldattr.NewLiteralRef("/attr1/subprop"),
527 Op: OperatorIn,
528 Values: []ldvalue.Value{ldvalue.String("a")},
529 },
530 jsonString: `{"attribute": "/attr1/subprop", "op": "in", "values": ["a"], "negate": false}`,
531 },
532 {
533 name: "invalid attribute ref",
534 clause: Clause{
535 ContextKind: ldcontext.Kind("user"),
536 Attribute: ldattr.NewRef("///"),
537 Op: OperatorIn,
538 Values: []ldvalue.Value{ldvalue.String("a")},
539 },
540 jsonString: `{"contextKind": "user", "attribute": "///", "op": "in", "values": ["a"], "negate": false}`,
541 },
542 {
543 name: "with segmentMatch operator",
544 clause: Clause{
545 Op: OperatorSegmentMatch,
546 Values: []ldvalue.Value{ldvalue.String("a")},
547 },
548 jsonString: `{"attribute": "", "op": "segmentMatch", "values": ["a"], "negate": false}`,
549
550 },
551 {
552 name: "negated",
553 clause: Clause{
554 Attribute: ldattr.NewLiteralRef("key"),
555 Op: OperatorIn,
556 Values: []ldvalue.Value{ldvalue.String("a")},
557 Negate: true,
558 },
559 jsonString: `{"attribute": "key", "op": "in", "values": ["a"], "negate": true}`,
560 },
561 }
562 }
563
564 func makeRolloutSerializationTestParams() []rolloutSerializationTestParams {
565 basicVariations := []WeightedVariation{{Variation: 1, Weight: 100000}}
566 basicVariationsJSON := `[{"variation": 1, "weight": 100000}]`
567
568 return []rolloutSerializationTestParams{
569 {
570 name: "simple",
571 rollout: Rollout{Variations: basicVariations},
572 jsonString: `{"variations": ` + basicVariationsJSON + `}`,
573 },
574 {
575 name: "with context kind",
576 rollout: Rollout{
577 ContextKind: ldcontext.Kind("org"),
578 Variations: basicVariations,
579 },
580 jsonString: `{"contextKind": "org", "variations": ` + basicVariationsJSON + `}`,
581 },
582 {
583 name: "with bucketBy",
584 rollout: Rollout{
585 BucketBy: ldattr.NewLiteralRef("name"),
586 Variations: basicVariations,
587 },
588 jsonString: `{"bucketBy": "name", "variations": ` + basicVariationsJSON + `}`,
589 },
590 {
591 name: "with contextKind and complex bucketBy ref",
592 rollout: Rollout{
593 ContextKind: ldcontext.Kind("user"),
594 BucketBy: ldattr.NewRef("/attr1/subprop"),
595 Variations: basicVariations,
596 },
597 jsonString: `{"contextKind": "user", "bucketBy": "/attr1/subprop", "variations": ` + basicVariationsJSON + `}`,
598 },
599 {
600 name: "bucketBy is treated as plain name and not path when kind is omitted",
601 rollout: Rollout{
602 BucketBy: ldattr.NewLiteralRef("/attr1/subprop"),
603 Variations: basicVariations,
604 },
605 jsonString: `{"bucketBy": "/attr1/subprop", "variations": ` + basicVariationsJSON + `}`,
606 },
607 {
608 name: "invalid bucketBy ref",
609 rollout: Rollout{
610 ContextKind: ldcontext.Kind("user"),
611 BucketBy: ldattr.NewRef("///"),
612 Variations: basicVariations,
613 },
614 jsonString: `{"contextKind": "user", "bucketBy": "///", "variations": ` + basicVariationsJSON + `}`,
615 },
616 {
617 name: "simple experiment",
618 rollout: Rollout{
619 Kind: RolloutKindExperiment,
620 Variations: basicVariations,
621 },
622 jsonString: `{"kind": "experiment", "variations": ` + basicVariationsJSON + `}`,
623 jsonAltInputs: []string{`{"kind": "experiment", "seed": null, "variations": ` + basicVariationsJSON + `}`},
624 },
625 {
626 name: "experiment with seed",
627 rollout: Rollout{
628 Kind: RolloutKindExperiment,
629 Seed: ldvalue.NewOptionalInt(12345),
630 Variations: basicVariations,
631 },
632 jsonString: `{"kind": "experiment", "seed": 12345, "variations": ` + basicVariationsJSON + `}`,
633 },
634 {
635 name: "experiment with untracked",
636 rollout: Rollout{
637 Kind: RolloutKindExperiment,
638 Variations: []WeightedVariation{
639 {Variation: 0, Weight: 75000},
640 {Variation: 1, Weight: 25000, Untracked: true},
641 },
642 },
643 jsonString: `{"kind": "experiment", "variations": [` +
644 `{"variation": 0, "weight": 75000}, {"variation": 1, "weight": 25000, "untracked": true}]}`,
645 },
646 }
647 }
648
649 func mergeDefaultProperties(output json.RawMessage, defaults map[string]interface{}) json.RawMessage {
650 var parsedOutput map[string]interface{}
651 if err := json.Unmarshal(output, &parsedOutput); err != nil {
652 return output
653 }
654 outMap := make(map[string]interface{})
655 for k, v := range parsedOutput {
656 outMap[k] = v
657 }
658 for k, v := range defaults {
659 if _, found := outMap[k]; !found {
660 outMap[k] = v
661 }
662 }
663 data, err := json.Marshal(outMap)
664 if err != nil {
665 return output
666 }
667 return data
668 }
669
View as plain text